Skip to content

02. Models, views, routing — the developer's daily surface

~14 min read. The internals explain what each door does. Day-to-day, you write models, views, URLs, and templates. This chapter is the working developer's catalogue — what to choose, what to avoid, and why.

Builds on: 01-orm-request-cycle-internals.md.

The previous chapter showed the ORM in action. This chapter is the surface you touch every day: defining models with the right field types, writing views (function-based vs. class-based), routing URLs, processing forms, rendering templates. Each section is a short catalogue of the choices and their trade-offs.


1) Models — fields, relationships, and the catalogue

A Django model is a Python class mapping to a database table.

class Order(models.Model):
    customer = models.ForeignKey('Customer', on_delete=models.PROTECT, related_name='orders')
    status = models.CharField(max_length=20, choices=[
        ('pending', 'Pending'),
        ('paid', 'Paid'),
        ('shipped', 'Shipped'),
        ('cancelled', 'Cancelled'),
    ], default='pending', db_index=True)
    amount = models.DecimalField(max_digits=12, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
    updated_at = models.DateTimeField(auto_now=True)
    metadata = models.JSONField(default=dict, blank=True)

    class Meta:
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['customer', '-created_at'], name='orders_cust_created_idx'),
        ]

    def __str__(self):
        return f'Order #{self.id} ({self.status})'

The non-obvious choices:

  • on_delete=models.PROTECT vs. CASCADE vs. SET_NULL. PROTECT refuses to delete a parent that has children — the safer default for orders. CASCADE deletes children — useful for tightly-owned data (an Order's OrderItem rows). SET_NULL requires null=True on the field. Choose based on whether the child row is independently meaningful.
  • db_index=True on status and created_at. The most-filtered columns deserve indexes. Composite indexes (in Meta.indexes) handle multi-column lookups.
  • DecimalField for money. Never FloatField. Currency arithmetic in floating point produces incorrect totals at the rupee level.
  • auto_now_add vs. auto_now. The first runs only on insert; the second runs on every save. created_at uses one; updated_at uses the other.
  • JSONField for flexible attributes. Useful for metadata that varies per row (e.g., shipping options, integration payloads). Cannot be efficiently filtered without GIN indexes; Postgres supports them; SQLite does not.

2) Migrations — the schema as code

Django migrations are Python files that evolve the schema.

# After changing a model:
python manage.py makemigrations
# Creates myapp/migrations/0042_order_metadata.py

# Apply:
python manage.py migrate

Migrations are committed to git. The database state is the sum of applied migrations. Three patterns matter in production:

Squash old migrations periodically. A long-lived project accumulates hundreds of migrations. python manage.py squashmigrations myapp 0001 0040 replaces 40 migrations with one. Speed up tests; reduce noise.

Schema migrations vs. data migrations. Schema migrations change the table (add column, drop column, add index). Data migrations change rows (RunPython). Data migrations should be idempotent (run twice without harm) because partial failures retry.

Backwards-incompatible migrations. A migration that renames a column while old code is still running breaks. Two-deploy pattern: deploy 1 adds the new column and writes to both; backfill old → new; deploy 2 reads from new and drops old. Never deploy a single migration that breaks the running version.


3) Views — function-based vs. class-based

A view is a callable that takes an HttpRequest and returns an HttpResponse.

Function-based view (FBV):

def order_detail(request, order_id):
    order = get_object_or_404(Order, pk=order_id)
    if request.method == 'POST':
        form = OrderForm(request.POST, instance=order)
        if form.is_valid():
            form.save()
            return redirect('order_detail', order_id=order.id)
    else:
        form = OrderForm(instance=order)
    return render(request, 'orders/detail.html', {'order': order, 'form': form})

Class-based view (CBV):

class OrderDetailView(UpdateView):
    model = Order
    form_class = OrderForm
    template_name = 'orders/detail.html'
    success_url = reverse_lazy('order_list')

    def get_object(self, queryset=None):
        return get_object_or_404(Order, pk=self.kwargs['order_id'])

The CBV is shorter when the pattern fits (CRUD on a model). The FBV is clearer when the logic is custom (orchestration of multiple models, branching responses, side effects).

The mature pattern: use generic CBVs (ListView, DetailView, CreateView, UpdateView, DeleteView) for plain CRUD; use FBVs for anything else. Mixing both in one project is normal; choosing one religiously is not.

Avoid: deep inheritance chains over CBVs (MyMixinedAuthenticatedFilteredView(SomeMixin, AnotherMixin, ListView)). The mixin tower is hard to read; explicit FBVs win.


4) URLs — patterns, names, and reverse

URL configuration is a Python list mapping patterns to views.

# myapp/urls.py
from django.urls import path, include

urlpatterns = [
    path('orders/', OrderListView.as_view(), name='order_list'),
    path('orders/<int:order_id>/', OrderDetailView.as_view(), name='order_detail'),
    path('orders/<int:order_id>/ship/', ship_order, name='order_ship'),
    path('api/', include('myapp.api.urls')),
]

# project/urls.py
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('myapp.urls')),
]

Three discipline points:

Always name URLs. name='order_detail' lets templates and views call {% url 'order_detail' order_id=42 %} or reverse('order_detail', kwargs={'order_id': 42}). Hardcoding /orders/42/ in templates ties you to the URL structure forever.

Use path converters, not regex. <int:order_id> is preferred over (?P<order_id>\d+). Same effect, cleaner read.

Namespace included URLConfs. path('api/', include('myapp.api.urls', namespace='api')) lets reverse('api:order_detail') distinguish from the HTML view.


5) Forms — validation and the round-trip

Forms validate incoming data and produce errors for re-rendering.

class OrderForm(forms.ModelForm):
    class Meta:
        model = Order
        fields = ['customer', 'status', 'amount']

    def clean_amount(self):
        amount = self.cleaned_data['amount']
        if amount < 0:
            raise ValidationError("Amount must be non-negative.")
        return amount

    def clean(self):
        cleaned = super().clean()
        if cleaned.get('status') == 'shipped' and not cleaned.get('customer'):
            raise ValidationError("Cannot ship an order with no customer.")
        return cleaned

The view pattern is the POST/redirect/GET cycle:

def order_create(request):
    if request.method == 'POST':
        form = OrderForm(request.POST)
        if form.is_valid():
            order = form.save()
            return redirect('order_detail', order_id=order.id)
    else:
        form = OrderForm()
    return render(request, 'orders/create.html', {'form': form})

For API endpoints, Django REST Framework's Serializer replaces Form with the same shape (is_valid(), save(), errors as JSON).


6) Templates — the rendering layer

Django's template language is intentionally restricted: no arbitrary Python in templates, only declared template tags and filters.

{# orders/detail.html #}
{% extends 'base.html' %}

{% block content %}
  <h1>Order #{{ order.id }}</h1>
  <p>Status: <strong>{{ order.status|title }}</strong></p>
  <p>Total: ₹{{ order.amount|floatformat:2 }}</p>

  <h2>Items</h2>
  <ul>
    {% for item in order.items.all %}
      <li>{{ item.product.name }} × {{ item.qty }}</li>
    {% empty %}
      <li>No items.</li>
    {% endfor %}
  </ul>

  <form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Save</button>
  </form>
{% endblock %}

Three things to remember:

  • {% csrf_token %} in every form. Without it, the CSRF middleware rejects the POST.
  • order.items.all evaluates the queryset. If items is not prefetched on the view's queryset, this is N+1. The template makes the optimisation harder to see; the chapter-01 discipline still applies.
  • Custom template tags and filters live in templatetags/. Keep template logic minimal; complex logic belongs in the view or a service function.

For modern frontends, the template layer often shrinks — Django becomes a JSON API (via DRF) and the front-end is React/Vue/HTMX. The template language is still useful for emails, server-rendered admin views, and small dashboards.


7) The admin — the auto-generated UI

# orders/admin.py
from django.contrib import admin
from .models import Order, OrderItem

class OrderItemInline(admin.TabularInline):
    model = OrderItem
    extra = 0

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ['id', 'customer', 'status', 'amount', 'created_at']
    list_filter = ['status', 'created_at']
    search_fields = ['id', 'customer__email']
    inlines = [OrderItemInline]
    autocomplete_fields = ['customer']

Three minutes of configuration produces a working CRUD UI for staff. The admin is unmatched for internal tools and never quite right for customer-facing UI.

The two patterns:

  • Use the admin for staff. Internal back-office workflows where staff trust the data model. List filters, search, bulk actions, model history — all there.
  • Do not use the admin for end users. Customisation past a point is more work than building a dedicated UI; the admin's mental model leaks (it shows the database schema, not a user task).

8) Permissions and authentication

class OrderListView(LoginRequiredMixin, ListView):
    model = Order

    def get_queryset(self):
        # Multi-tenant scoping — every list query filters by the user's tenant.
        return Order.objects.filter(customer__tenant=self.request.user.tenant)

Three layers:

  • Authentication. Who is the user? Built-in AuthenticationMiddleware sets request.user from the session.
  • Permissions. What can the user do? Built-in Permission model assigns add_order, change_order, delete_order, view_order per user/group. Customisable via has_perm().
  • Object-level permissions. Can the user see this order? Library: django-guardian. Pattern: enforce in get_queryset() so the user only ever sees rows their tenant owns. Defence-in-depth: also check in get_object() for object detail views.

The most common production mistake is enforcing only at the view layer ("staff users see this list") without filtering the queryset. A user can craft /orders/<id>/ for an ID they should not see; the view's permission check passes (they are staff); the object is returned. Always scope the queryset.


9) Settings — the production-vs-dev split

A settings.py file is one of the trickiest production surfaces.

# settings/base.py — shared
INSTALLED_APPS = [...]
MIDDLEWARE = [...]

# settings/dev.py
from .base import *
DEBUG = True
DATABASES = {'default': sqlite3 ...}

# settings/production.py
from .base import *
DEBUG = False
ALLOWED_HOSTS = ['.example.com']
DATABASES = {'default': postgres ...}
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

Sensitive values (SECRET_KEY, database password) come from environment variables, never committed.

import os
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
DATABASES['default']['PASSWORD'] = os.environ['DB_PASSWORD']

Libraries to consider: django-environ for env-var parsing, pydantic for typed settings. Avoid: local_settings.py patterns that get out of sync; secrets in committed files.


10) Project layout — apps and the team's third year

A new Django project starts with one app. By year three, the project has 20 apps, and the apps' boundaries are the codebase's most-debated topic.

Three patterns that survive:

  • One app per bounded context. orders/, payments/, inventory/, accounts/. Each app's models, views, URLs, templates live together. Apps depend on each other through clear interfaces (service functions, signals, public APIs), not through tangled imports.
  • services.py for cross-cutting logic. Each app has a services.py (or services/) module — pure-Python functions that encapsulate business logic. Views call services; services call models; models stay thin. This is the pattern that lets you test business logic without HTTP.
  • api/ subpackage for REST. orders/api/views.py, orders/api/serializers.py, orders/api/urls.py. Keeps HTML views and JSON views distinct; both share models.

What collapses by year three:

  • Fat models. Putting all logic on the model class produces an unmaintainable thousand-line models.py. Push logic to services.
  • God views. A view that does ORM queries, validates forms, sends emails, calls third-party APIs, renders HTML. Push everything but the HTTP shape to services.
  • Implicit cross-app dependencies via signals. "When an Order saves, the inventory app's signal handler updates stock." Mature teams replace this with explicit service calls; signals are kept for cross-cutting concerns (audit log) only.

Operational signals

Healthy. Apps have clear ownership; service functions are tested; admin reflects the data model; permissions enforced both at the view and the queryset.

First degrading metric. A models.py over 500 lines, or a view function over 100 lines. The boundaries have eroded.

Misleading metric. Number of apps. Many small apps with circular dependencies is worse than few well-bounded apps.

Expert graph. Per-app dependency graph (which apps import from which); test coverage per service module; admin usage analytics.


Where this appears in production

  • Instagram (early Django) — separated business logic into services; the pattern is canonical now.
  • Pinterest — uses Django's admin for ops; their patterns around autocomplete_fields and list_filter are widely copied.
  • Dropbox (parts of admin tooling) — Django for internal tooling; services.py per app pattern.
  • Eventbrite — multi-tenant get_queryset scoping; well-documented patterns.
  • Mozilla — large codebase with strict app boundaries; their PR review focuses on cross-app coupling.
  • The Washington Post — Django CMS; CBV vs. FBV split decided per feature.
  • Disney+ Hotstar (backend tooling) — Django for some internal portals; django-guardian for per-object permissions.
  • Goa-based real-estate SaaS — multi-tenant Django; queryset scoping enforced via custom middleware.

Recall / checkpoint

  1. What is the difference between on_delete=CASCADE and PROTECT?
  2. Why use DecimalField for money, not FloatField?
  3. When do you choose a class-based view over a function-based view?
  4. What is the POST/redirect/GET cycle?
  5. Why is filtering at the queryset level (not just the view) load-bearing for security?
  6. What is a service module and what does it solve?
  7. How do migrations evolve safely across deploys?

Interview Q&A

Q1. A multi-tenant Django app: a tenant reports they can see another tenant's order detail. Walk through the diagnosis and fix. The diagnosis is permission enforcement only at the view layer, not the queryset. The view checked "user is staff" and returned the object. The fix is to filter the queryset in get_queryset() to the user's tenant; then get_object() (which uses the queryset) returns 404 for cross-tenant IDs. Add object-level permission checks as defence-in-depth. The principle is: scope the queryset; everything downstream inherits the scope. Common wrong answer to avoid: "add a permission check in the view" — necessary but not sufficient; the queryset is the structural defence.

Q2. The team's models.py is 1,800 lines. What is the structural problem and what is the fix? Fat models are the problem: business logic on the model class produces an unmaintainable file. The fix is services.py per app — pure-Python functions encapsulating business logic, called from views, callable from management commands and tests. Models stay thin (fields, relationships, basic validation). The migration is gradual: extract one service per sprint, keep the model methods that delegate to services as deprecated. Common wrong answer to avoid: "split into more models" — the symptom, not the cause.

Q3. A migration adds a non-nullable column to a 50M-row table. Walk through the deploy plan. Single migration that adds NOT NULL will block the table during the backfill — potentially hours of downtime. The pattern: (1) deploy a migration that adds the column as nullable with a default, (2) backfill in a data migration or background job that processes in batches, (3) deploy a second migration that flips the column to non-nullable after the backfill completes. Each step is reversible. Tools: django-migration-linter flags risky migrations in CI. Common wrong answer to avoid: "just run it during a maintenance window" — a window for 50M rows can be 8+ hours; better to roll out incrementally.

Q4. The team uses signals for sending emails on Order creation. A new bulk import path doesn't trigger emails. Walk through the response. The diagnosis is correct: bulk_create() skips signals. The deeper question is whether signals should own this logic at all. Mature teams move side effects to explicit service functions called from both the save() path and the bulk path; signals are reserved for cross-cutting concerns (audit log) and integration points (allauth listening for user creation). Refactor: extract send_order_email(order) service; call from Order.save() (or a service function create_order()) and from the bulk path. Remove the signal. Common wrong answer to avoid: "use signals on the bulk path" — signals don't fire there by design.

Q5. The admin is being used as the customer-facing UI for a small SaaS. What is the trade-off and the threshold to migrate? Trade-off: speed-to-build vs. user experience. The admin shows the database schema, not a user task; permissions and customisation past a point cost more than building a dedicated UI. Threshold: when the admin's "list of orders" no longer matches what a user wants to do (they want a dashboard, not a CRUD table), it is time to move. The migration is gradual: build the dashboard alongside the admin; route customers to the dashboard; keep the admin for staff only. Common wrong answer to avoid: "the admin is enough forever" — it eventually constrains the product.

Q6. How do you structure URLs for a versioned REST API in Django? /api/v1/ namespace via include('myapp.api.v1.urls', namespace='api-v1'). Each version gets its own URLconf, its own serializers, its own views. Shared models and services live in the main app. The version namespace lets you reverse('api-v1:order_detail') and keep v2 alongside without conflict. URL parameters (path or query) are part of the versioned contract. Common wrong answer to avoid: "version in headers only" — works but harder to debug; URL versioning is more discoverable.


Operational memory

This chapter explained the daily Django surface: model design, migrations, views, URLs, forms, templates, admin, permissions, settings, project layout. The important idea is that Django's defaults are good for fast starts, but production maturity requires explicit boundaries — services per app, queryset scoping for security, services replacing fat models and god views.

You learned to choose field types, structure URLs, write views (FBV or CBV per fit), enforce permissions at the queryset, and lay out a project that survives the team's third year. That solves the opening question of "how do I actually write Django?" because every surface has a default and a known evolution.

Carry this diagnostic forward: when a Django codebase smells like cargo cult, ask which of these surfaces has degraded — fat models, god views, ungated querysets, signal abuse, or admin overuse. Each has a known fix.

Remember:

  • on_delete=PROTECT for independently meaningful children; CASCADE for tightly-owned.
  • DecimalField for money; never FloatField.
  • Use CBV for plain CRUD, FBV for everything else.
  • Scope at the queryset; defence-in-depth at the view.
  • services.py per app keeps models and views thin.
  • Migrations evolve in steps; never a single deploy that breaks the running version.

Bridge. The development surface is set. Production has its own surface: gunicorn workers, static files, database pooling, async, caching, the long tail of operational gotchas. The next chapter is that surface. → 03-deployment-scaling-prod-gotchas.md