The MVC pattern doesn’t actually separate your code into three distinct boxes; it’s more about separating responsibilities that often get tangled up.

Let’s see it in action. Imagine a simple web application displaying a list of users.

# --- models.py ---
from typing import List

class User:
    def __init__(self, user_id: int, username: str):
        self.user_id = user_id
        self.username = username

class UserModel:
    def get_all_users(self) -> List[User]:
        # In a real app, this would query a database.
        # For demonstration, we'll use mock data.
        return [
            User(1, "Alice"),
            User(2, "Bob"),
            User(3, "Charlie"),
        ]

# --- views.py ---
from typing import List
from .models import User

class UserListView:
    def render(self, users: List[User]) -> str:
        html = "<h1>User List</h1><ul>"
        for user in users:
            html += f"<li>ID: {user.user_id}, Username: {user.username}</li>"
        html += "</ul>"
        return html

# --- controllers.py ---
from .models import UserModel
from .views import UserListView

class UserController:
    def __init__(self):
        self.user_model = UserModel()
        self.user_list_view = UserListView()

    def list_users(self) -> str:
        # 1. Get data from the Model
        users = self.user_model.get_all_users()
        # 2. Pass data to the View for rendering
        rendered_html = self.user_list_view.render(users)
        return rendered_html

# --- main.py (simulating a web request) ---
from controllers import UserController

if __name__ == "__main__":
    user_controller = UserController()
    response_html = user_controller.list_users()
    print(response_html)

The core problem MVC solves is the tangled mess of presentation logic (how something looks), business logic (how data is managed), and data access (where data lives). In a pre-MVC world, you might have HTML mixed directly with database queries and user validation all in one giant file. This makes the code brittle, hard to read, and a nightmare to test or modify.

Here’s how the responsibilities break down:

  • Model: This is your data and the rules around it. It knows what data exists (like User objects) and how to get or save it (like UserModel.get_all_users()). It has no idea about HTML or how the data will be displayed. It’s the brain for your data.
  • View: This is what the user sees. It’s responsible for presentation. The UserListView.render() method takes the data (a list of User objects) and formats it into HTML. It doesn’t know where the data came from, only how to display it. It’s the face of your application.
  • Controller: This is the intermediary. It receives requests (like a web server getting a URL), asks the Model for the necessary data, and then tells the View which data to display. The UserController.list_users() method orchestrates this. It’s the handshake between the user’s request and the application’s response.

When a web framework receives a request for /users, it typically routes it to the appropriate controller action (e.g., UserController.list_users). The controller then interacts with the model to fetch data, and finally passes that data to the view for rendering into HTML, which is then sent back to the browser.

Crucially, the separation isn’t just about code files. A well-designed MVC application allows you to change the view (e.g., switch from HTML to JSON for an API) without touching the model, or change the model (e.g., switch from a SQL database to a NoSQL one) without altering the view’s rendering logic, as long as the data contract between them remains the same. The controller adapts to these changes by knowing which model methods to call and which view methods to use.

The most counterintuitive aspect for newcomers is often how the "view" can be stateless. It doesn’t hold onto data between requests. It’s a pure function: given data, it produces output. This statelessness is key to testability and scalability, as you don’t have to worry about lingering state causing problems across different user interactions or concurrent requests.

The next logical step is understanding how this pattern scales, particularly the challenges of maintaining separation as the application grows.

Want structured learning?

Take the full Monolith course →