MVT w Django – Model, View i Template na przykładzie

Piotr Dul

04-11-2026

Czym jest MVT w Django, jak model, widok i szablon współpracują przy jednym żądaniu i dlaczego „view” to nie to samo co widok w Laravelu. Z działającym przykładem.

MVT w Django – Model, View i Template na przykładzie
MVT (Model-View-Template) to wzorzec architektury Django: model opisuje dane i tabelę w bazie, widok (*view*) to funkcja lub klasa, która obsługuje żądanie i wybiera dane, a szablon (*template*) to plik HTML, który te dane wyświetla. Rolę kontrolera z MVC przejmuje w Django widok razem z routingiem w urls.py, dlatego „view” w Django odpowiada kontrolerowi, a nie szablonowi.

Hasło „MVT” pojawia się w każdym wstępie do Django, ale rzadko ktoś pokazuje, gdzie dokładnie leży granica między M, V i T. Ten artykuł prowadzi przez jedną małą aplikację, listę wpisów i stronę wpisu, i na niej pokazuje, co robi każda z trzech warstw. Ogólne wprowadzenie do frameworka znajdziesz w artykule co to jest Django i do czego służy, tutaj skupiamy się na działającym kodzie. Wszystkie przykłady uruchomiliśmy w świeżym projekcie (Django 6.1.3, Python 3.13), a wyniki w tekście pochodzą z tego uruchomienia.

Czym jest MVT w Django i czym różni się od MVC?

MVT to trzy warstwy aplikacji z jasno przypisanymi zadaniami:

WarstwaZa co odpowiadaGdzie leży w projekcie
Modelopis danych i tabeli, zapytania do bazy (ORM)models.py
View (widok)przyjmuje żądanie, pobiera dane, zwraca odpowiedźviews.py
Template (szablon)wygląd: HTML z miejscami na danetemplates/

Dochodzi do tego czwarty element, który nie ma własnej litery: plik urls.py, czyli routing. To on dopasowuje adres URL do widoku.

W klasycznym MVC (Model-View-Controller) podział jest podobny, ale nazwy są przesunięte. Porównanie:

MVCMVT w DjangoUwaga
ModelModelto samo
ControllerView + urls.py„view” w Django robi to, co kontroler
ViewTemplate„widok” z MVC to szablon w Django

To jest najważniejsza pułapka językowa: „view” w Django nie jest plikiem HTML. HTML to szablon. Jeśli znasz Laravela, tam „widok” oznacza plik Blade (opisujemy go w artykule Blade w Laravel), więc te same słowa znaczą coś innego w obu frameworkach.

Jak wygląda projekt Django, który pokazuje MVT?

Projekt tworzysz poleceniami django-admin startproject i manage.py startapp. W naszym przykładzie projekt nazywa się config, a aplikacja blog:

django-admin startproject config .
python manage.py startapp blog

Aplikację trzeba dopisać do INSTALLED_APPS w config/settings.py, inaczej Django jej nie zobaczy:

INSTALLED_APPS = [
    # ... domyślne aplikacje Django ...
    "blog",
]

Po tych krokach struktura wygląda tak: blog/models.py (M), blog/views.py (V), blog/urls.py (routing) i blog/templates/blog/ (T). Szablony leżą w podkatalogu o nazwie aplikacji, bo Django zbiera katalogi templates/ ze wszystkich aplikacji w jedno miejsce, a powtarzająca się nazwa blog/ chroni przed zderzeniem nazw plików z innymi aplikacjami.

Jak wygląda Model w MVT?

Model to klasa Pythona, z której Django buduje tabelę. Nasz wpis ma tytuł, treść, flagę publikacji i datę utworzenia:

from django.db import models


class Post(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()
    published = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title

Po python manage.py makemigrations blog Django odpowiedział Create model Post, a migrate utworzył tabelę. Model nic nie wie o HTML-u ani o adresach URL: odpowiada tylko za dane. Zapytania piszesz przez ORM, np. Post.objects.filter(published=True), a nie ręcznym SQL-em.

Jak wygląda View w MVT?

Widok to funkcja, która dostaje obiekt żądania (request) i zwraca odpowiedź. Tu napisaliśmy dwa: listę wpisów oraz stronę pojedynczego wpisu.

from django.shortcuts import get_object_or_404, render

from .models import Post


def post_list(request):
    posts = Post.objects.filter(published=True).order_by("-created_at")
    return render(request, "blog/post_list.html", {"posts": posts})


def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk, published=True)
    return render(request, "blog/post_detail.html", {"post": post})

Widok robi trzy rzeczy: pobiera dane z modelu, przekazuje je do szablonu jako słownik (kontekst, context) i zwraca gotową odpowiedź z funkcji render. Nie zawiera ani linijki HTML-a. Funkcja get_object_or_404 zwraca stronę 404, gdy wpisu nie ma albo gdy nie jest opublikowany, czyli widok jest też miejscem na reguły dostępu.

Jak działa routing w urls.py?

Routing łączy adres z widokiem. Aplikacja ma własny plik blog/urls.py:

from django.urls import path

from . import views

app_name = "blog"

urlpatterns = [
    path("", views.post_list, name="post_list"),
    path("<int:pk>/", views.post_detail, name="post_detail"),
]

a główny config/urls.py dołącza go pod prefiksem posts/:

from django.urls import include, path

urlpatterns = [
    path("posts/", include("blog.urls")),
]

Fragment <int:pk> przechwytuje liczbę z adresu i przekazuje ją widokowi jako argument pk. Zmienna app_name = "blog" tworzy przestrzeń nazw (namespace): nazwę widoku zapisujesz potem jako blog:post_detail. Do tej pułapki wracamy w sekcji o błędach.

Jak wygląda Template w MVT?

Szablon to HTML z dwoma rodzajami wstawek: {{ zmienna }} wypisuje wartość, a {% tag %} wykonuje logikę (pętla, warunek, dziedziczenie). Zaczynamy od szablonu bazowego blog/base.html:

<!DOCTYPE html>
<html lang="pl">
<head><title>{% block title %}Blog{% endblock %}</title></head>
<body>
{% block content %}{% endblock %}
</body>
</html>

Szablon listy rozszerza go tagiem extends i wypełnia bloki:

{% extends "blog/base.html" %}
{% block title %}Posty{% endblock %}
{% block content %}
<h1>Posty</h1>
{% for post in posts %}
  <li><a href="{% url 'blog:post_detail' post.pk %}">{{ post.title }}</a></li>
{% empty %}
  <p>Brak postów.</p>
{% endfor %}
{% endblock %}

Tag {% for ... %}{% empty %} obsługuje pustą listę w jednej konstrukcji, a {% url 'blog:post_detail' post.pk %} buduje adres z nazwy widoku, więc zmiana ścieżki w urls.py nie wymaga poprawek w szablonach. Mechanizm dziedziczenia jest tu odpowiednikiem layoutów z Blade (@extends, @yield).

Co się dzieje po kolei, gdy wchodzisz na /posts/?

Droga jednego żądania przez wszystkie warstwy:

KrokCo się dziejeWarstwa
1przeglądarka wysyła GET /posts/—
2Django szuka dopasowania w urls.py: posts/ → blog.urls → post_listrouting
3wywołuje post_list(request)View
4widok pyta model o opublikowane wpisyModel
5widok przekazuje wpisy do render() razem z nazwą szablonuView
6Django składa szablon z post_list.html i base.htmlTemplate
7gotowy HTML wraca do przeglądarkiodpowiedź

Sprawdziliśmy to w teście. Dla pustej bazy GET /posts/ zwrócił kod 200 i <p>Brak postów.</p>. Po dodaniu jednego opublikowanego wpisu „Pierwszy” i jednego szkicu na liście pojawił się tylko pierwszy:

<h1>Posty</h1>

  <li><a href="/posts/1/">Pierwszy</a></li>

Szkic był widoczny dla autora w bazie, ale nie dla odwiedzającego: wejście na jego stronę zwróciło 404, tak samo jak adres /posts/999/. Klient testowy pokazał też, które szablony złożyły stronę: blog/post_list.html i blog/base.html.

Czy Django escapuje dane w szablonie tak jak Blade?

Tak, domyślnie. {{ zmienna }} zamienia znaki specjalne HTML na encje, a filtr |safe albo tag {% autoescape off %} wyłącza tę ochronę. Zapisaliśmy wpis z treścią <script>alert(1)</script> i wyrenderowaliśmy go dwa razy:

<div>{{ post.body }}</div>
<div>{{ post.body|safe }}</div>

Wynik:

<div>&lt;script&gt;alert(1)&lt;/script&gt;</div>
<div><script>alert(1)</script></div>

Pierwszy wiersz to bezpieczny tekst, a w drugim przeglądarka wykona skrypt. Filtr |safe ma więc to samo ryzyko co {!! !!} w Blade: jeśli dane pochodzą od użytkownika, otwierasz drogę do ataku opisanego w artykule XSS w JavaScript. Zasada jest ta sama: |safe tylko dla HTML-a, który sam wygenerowałeś lub oczyściłeś.

Czym szablony Django różnią się od Blade?

Ta sama rola (T w MVT, V w MVC), ale inna filozofia. Różnice, które wynikają z naszych testów i dokumentacji:

CechaSzablony DjangoBlade (Laravel)
Składnia wypisania{{ zmienna }}{{ $zmienna }}
Logikatagi {% if %}, {% for %}dyrektywy @if, @foreach
Kod PHP/Pythona w szabloniecelowo ograniczony, nie napiszesz dowolnego kodu@php ... @endphp pozwala na dowolny kod
Brakująca zmiennacicho wypisuje pusty tekstbłąd Undefined variable
Dziedziczenie{% extends %} + {% block %}@extends + @section / @yield
Filtry{{ tekst|upper }}wywołanie funkcji PHP w {{ }}

Różnica w brakujących zmiennych jest ważna w praktyce. W teście Cześć {{ imie }}! bez przekazanej zmiennej dało Cześć !, a {{ post.nie_ma }} dało pusty tekst. Django nie zgłosi błędu, więc literówka w nazwie zmiennej potrafi tygodniami zostać niezauważona. Gdy strona wyświetla puste miejsce zamiast danych, sprawdź nazwę zmiennej w szablonie i w słowniku z widoku.

Jakie filtry i tagi szablonów warto znać na początek?

ElementPrzykładWynik w teście
upper{{ t|upper }} dla devmentorDEVMENTOR
truncatechars{{ t|truncatechars:6 }}devme…
default{{ n|default:'brak' }} dla pustego tekstubrak
length, join{{ l|length }}, {{ l|join:', ' }} dla [1, 2, 3]3, 1, 2, 3
date{{ post.created_at|date:"Y-m-d" }}2026-11-04
{% if %} z and/not{% if a and not b %}tak{% endif %}tak
{% include %}{% include 'blog/base.html' %}wstawia cały plik
{{ block.super }}w bloku title dzieckaBlog - X (treść bloku rodzica + dopisek)

Filtry łączysz znakiem | i wywołujesz z jednym argumentem po dwukropku. Skomplikowanych obliczeń nie umieszczaj w szablonie: przenieś je do widoku lub modelu.

Jakie błędy w MVT popełniają początkujący?

BłądObjawCo zrobić
Zła nazwa lub ścieżka szablonu w render()TemplateDoesNotExist: blog/nie_ma.htmlsprawdź, czy plik leży w templates/blog/ i czy nazwa zgadza się znak w znak
Aplikacja nie jest w INSTALLED_APPSteż TemplateDoesNotExist (wg dokumentacji; tego wariantu nie testowaliśmy)dopisz aplikację do INSTALLED_APPS
{% url 'post_detail' %} bez przestrzeni nazwNoReverseMatch: 'post_detail' is not a valid view function or pattern nameużyj blog:post_detail, jeśli w urls.py jest app_name = "blog"
Literówka w nazwie zmiennejpusty fragment strony, bez błęduporównaj nazwę w szablonie z kluczem w słowniku widoku
HTML i logika biznesowa w widokuwidok rośnie, trudno go testowaćHTML do szablonu, reguły do modelu lub osobnej funkcji
|safe na danych od użytkownikaluka XSSusuń |safe albo oczyść dane przed zapisem

Błąd NoReverseMatch potwierdziliśmy w teście: reverse("post_detail", args=[1]) zgłosił go, a reverse("blog:post_detail", args=[1]) zwrócił poprawnie /posts/1/.

Jaki błąd przy MVT popełniają początkujący najczęściej?

Najczęstsza pomyłka przy MVT to logika w szablonie albo HTML zbudowany w widoku z ciągów znaków. Prosta reguła: widok decyduje, co pokazać, szablon decyduje, jak to wygląda, a model mówi, czym są dane. Jeśli w szablonie piszesz coś, co wymagałoby testu jednostkowego, przenieś to do widoku lub modelu.

Gdzie nauczyć się Django w praktyce?

MVT najłatwiej zrozumieć, budując własną aplikację: model, kilka widoków, szablony i formularz. Taką drogę przechodzisz w kursie Python, który skupia się na Django i obejmuje m.in. modele, formularze i wdrożenie. Mentor sprawdza twój kod i wskazuje miejsca, w których logika wylądowała w złej warstwie.

Konsultacja i warsztaty devmentor.pl

Masz pytania?

Partnerzy

Firmy, z którymi współpracujemy

To partnerzy odpowiedzialni za staże po naszych kursach. Organizują je dla najlepszych absolwentów.

Zobacz wszystkich partnerów →
  • VR Medical
  • Sophona
  • Robot Force