Comprehending Class-Based Views in Django - The View Base Class

Class-Based Views, or CBVs, are one of the most debated features of Django. Compared to their counterparts, Function-Based Views (FBVs), CBVs can seem more confusing and harder to understand. In this series, Comprehending Class-Based Views in Django, we'll walk through CBVs in detail to understand how they work and how to use them.

To start off, we will go over the View base class, how a CBV is used in the URLconf, and how the View class routes the view logic for other classes that inherit from it.

Prerequisites

This article is aimed at those who may have tried using CBVs before and want to understand more about how they work. You will get the most from this article if you:

  • Have built a project with Django before
  • Have tried using a CBV at least once
  • Have a basic understanding of classes in Python

This article contains many incomplete code snippets that are used for illustrative purposes. If you have used Django before, understanding where these snippets fit into a project shouldn't be difficult. I've tried to add as much context as possible, but have left off much of the supporting code to keep the article to a digestible length.

How a CBV is called

Let's start by looking at how we would use a CBV in the URLconf compared to a FBV. Assuming we have a CBV called MyView and a FBV called my_view, in a project's urls.py file, we use them in the path() function as a part of urlpatterns.

# Class-Based View
path('new-cbv/', MyView.as_view(), name='new_cbv')

# Function-Based View
path('new-fbv/', my_view, name='new_fbv')

Django expects the second argument to path() to be a function. This means we can directly provide a FBV to path(). We provide my_view, and not my_view(), because we don't want to call the function. Django will call it later and use it appropriately.

Using a CBV is different. At first, we might expect that we could just pass the MyView class directly to path().

# Wrong way to use a CBV in the URLconf
path('new-cbv/', MyView, name='new_cbv')

This won't work though because path() isn't expecting a class as an argument for the view. We need to somehow get a function from the class. We do this by calling .as_view() on MyView. But with MyView.as_view() we are calling a function, not passing one directly like we did with the FBV. We don't know what .as_view() returns yet, but if path() expects a function, then .as_view() must return one. To find out what it does return we have to dive into Django's built-in View base class.

Diving into the View class

All CBVs inherit from View as their base class. A CBV may inherit from many different classes and mixins, but they all start with View. Let's look at the code behind View straight from the Django source.

class View:
"""
Intentionally simple parent class for all views. Only implements
dispatch-by-method and simple sanity checking.
"""

http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']

def __init__(self, **kwargs):
    """
    Constructor. Called in the URLconf; can contain helpful extra
    keyword arguments, and other things.
    """
    # Go through keyword arguments, and either save their values to our
    # instance, or raise an error.
    for key, value in kwargs.items():
        setattr(self, key, value)

@classonlymethod
def as_view(cls, **initkwargs):
    """Main entry point for a request-response process."""
    for key in initkwargs:
        if key in cls.http_method_names:
            raise TypeError(
                'The method name %s is not accepted as a keyword argument '
                'to %s().' % (key, cls.__name__)
            )
        if not hasattr(cls, key):
            raise TypeError("%s() received an invalid keyword %r. as_view "
                            "only accepts arguments that are already "
                            "attributes of the class." % (cls.__name__, key))

    def view(request, *args, **kwargs):
        self = cls(**initkwargs)
        self.setup(request, *args, **kwargs)
        if not hasattr(self, 'request'):
            raise AttributeError(
                "%s instance has no 'request' attribute. Did you override "
                "setup() and forget to call super()?" % cls.__name__
            )
        return self.dispatch(request, *args, **kwargs)
    view.view_class = cls
    view.view_initkwargs = initkwargs

    # take name and docstring from class
    update_wrapper(view, cls, updated=())

    # and possible attributes set by decorators
    # like csrf_exempt from dispatch
    update_wrapper(view, cls.dispatch, assigned=())
    return view

def setup(self, request, *args, **kwargs):
    """Initialize attributes shared by all view methods."""
    if hasattr(self, 'get') and not hasattr(self, 'head'):
        self.head = self.get
    self.request = request
    self.args = args
    self.kwargs = kwargs

def dispatch(self, request, *args, **kwargs):
    # Try to dispatch to the right method; if a method doesn't exist,
    # defer to the error handler. Also defer to the error handler if the
    # request method isn't on the approved list.
    if request.method.lower() in self.http_method_names:
        handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
    else:
        handler = self.http_method_not_allowed
    return handler(request, *args, **kwargs)

def http_method_not_allowed(self, request, *args, **kwargs):
    logger.warning(
        'Method Not Allowed (%s): %s', request.method, request.path,
        extra={'status_code': 405, 'request': request}
    )
    return HttpResponseNotAllowed(self._allowed_methods())

def options(self, request, *args, **kwargs):
    """Handle responding to requests for the OPTIONS HTTP verb."""
    response = HttpResponse()
    response['Allow'] = ', '.join(self._allowed_methods())
    response['Content-Length'] = '0'
    return response

def _allowed_methods(self):
    return [m.upper() for m in self.http_method_names if hasattr(self, m)]

There is a lot going on here, but we will work through it the way Django does starting with .as_view().

The as_view() method

Since we are calling .as_view() in the URLconf, it is the first part of View that gets called before an instance of the class is created.

@classonlymethod
def as_view(cls, **initkwargs):
    """Main entry point for a request-response process."""
    for key in initkwargs:
        if key in cls.http_method_names:
            raise TypeError(
                'The method name %s is not accepted as a keyword argument '
                'to %s().' % (key, cls.__name__)
            )
        if not hasattr(cls, key):
            raise TypeError("%s() received an invalid keyword %r. as_view "
                            "only accepts arguments that are already "
                            "attributes of the class." % (cls.__name__, key))

    def view(request, *args, **kwargs):
        self = cls(**initkwargs)
        self.setup(request, *args, **kwargs)
        if not hasattr(self, 'request'):
            raise AttributeError(
                "%s instance has no 'request' attribute. Did you override "
                "setup() and forget to call super()?" % cls.__name__
            )
        return self.dispatch(request, *args, **kwargs)
    view.view_class = cls
    view.view_initkwargs = initkwargs

    # take name and docstring from class
    update_wrapper(view, cls, updated=())

    # and possible attributes set by decorators
    # like csrf_exempt from dispatch
    update_wrapper(view, cls.dispatch, assigned=())
    return view

The @classonlymethod decorator is used with .as_view() to make sure it isn't called on an instance of the class but instead is only called directly on the class. This is a good time to point out that we aren't going to directly create an instance of a class when using a CBV.

new_view = View() # You won't do this

Instead, an instance will be created later as a result of .as_view().

.as_view() takes two arguments: cls, which is the class .as_view() is called on and is automatically passed to the method, and **initkwargs. **initkwargs are any keyword arguments that we pass to .as_view() when calling it that may be needed when an instance of the class is finally created. We'll see another set of keyword arguments soon, so keep in mind that **initkwargs is used during class instantiation. If we did have keyword arguments, we would pass them in the URLconf like this:

# Passing example keyword arguments to .as_view
path('new-cbv/', MyView.as_view(kwarg1=new_kwarg_1, kwarg2=new_kwarg_2), name='new_cbv')

The first code executed when .as_view() is called is to loop through initkwargs and perform two checks.

for key in initkwargs:
if key in cls.http_method_names:
    raise TypeError(
        'The method name %s is not accepted as a keyword argument '
        'to %s().' % (key, cls.__name__)
    )
if not hasattr(cls, key):
    raise TypeError("%s() received an invalid keyword %r. as_view "
                    "only accepts arguments that are already "
                    "attributes of the class." % (cls.__name__, key))

Each key in initkwargs is first checked against the http_method_names attribute of the View class which contains a list of the HTTP verbs.

http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']

If we tried to pass an HTTP verb as an argument to .as_view(), we would get an error because it could cause a problem with execution of the view logic.

Each key is also checked to make sure it matches an already existing attribute of the class. The View class doesn't have class attributes beside http_method_names, so lets take a quick look at the first few lines of Django's built-in RedirectView CBV.

class RedirectView(View):
    """Provide a redirect on any GET request."""
    permanent = False
    url = None
    pattern_name = None
    query_string = False

RedirectView has multiple class attributes that we could set via keyword arguments on .as_view(). But if we tried to pass a keyword argument that wasn't one of those listed, we would get an error message.

After checking initkwargs, we get to the part of .as_view() that ties us back to the original goal of figuring out what .as_view() returns and hoping it is a function since path() in the URLconf expects one.

A function, view(), is defined in .as_view() and if we jump to the bottom of .as_view(), we can see that it returns this function.

So now we know that

path('new-cbv/', MyView.as_view(), name='new_cbv')

will look like

path('new-cbv/', view, name='new_cbv')

once MyView.as_view() is evaluated, which looks the same as when an FBV is used. This means that view() will receive the same arguments an FBV would when Django calls it. We won't go into what Django does to call a view and how it does it, but you can learn more in the Django docs.

Before we get into the detail of view(), there are a couple remaining parts of MyView.as_view(). First, class attributes on view of view.view_class and view.view_initkwargs are set to the class of the view and initkwargs respectively.

Finally, two calls to update_wrapper() are made. This copies metadata from the class and the dispatch() method to view.

With .as_view() complete, we can now look at the view() function and what it does.

Creating the class instance with view()

What exactly does view() do when it is called? In short, it kicks off the chain of events that perform the view logic and ultimately return a response. Let's see how it does that.

def view(request, *args, **kwargs):
    self = cls(**initkwargs)
    self.setup(request, *args, **kwargs)
    if not hasattr(self, 'request'):
        raise AttributeError(
            "%s instance has no 'request' attribute. Did you override "
            "setup() and forget to call super()?" % cls.__name__
        )
    return self.dispatch(request, *args, **kwargs)

view() takes three arguments: request, *args, and **kwargs. These are provided by Django when it calls the view with *args and **kwargs coming from the URL pattern. It's important to not confuse **kwargs with initkwargs we saw earlier. **initkwargs come from the call to as_view() while **kwargs come from patterns matched in the URL.

The first thing view() does is create an instance of the class by passing it **initkwargs and assigning the instance to self. If we go back to the beginning of the View class, we can see that the class attributes are set in the __init__() method with **initkwargs being called **kwargs locally.

def __init__(self, **kwargs):
    """
    Constructor. Called in the URLconf; can contain helpful extra
    keyword arguments, and other things.
    """
    # Go through keyword arguments, and either save their values to our
    # instance, or raise an error.
    for key, value in kwargs.items():
        setattr(self, key, value)

Once the class instance is created, the setup() method is called which takes the same three arguments passed to view(). It saves the arguments to the class instance which makes them available to all later methods of the view.

def setup(self, request, *args, **kwargs):
    """Initialize attributes shared by all view methods."""
    if hasattr(self, 'get') and not hasattr(self, 'head'):
        self.head = self.get
    self.request = request
    self.args = args
    self.kwargs = kwargs

It also checks if self has a get attribute and a head attribute. If it has get but not head, it creates head and assigns get to it.

After setup() is called, there is one more check in the view() function to make sure that self has a request attribute.

if not hasattr(self, 'request'):
    raise AttributeError(
        "%s instance has no 'request' attribute. Did you override "
        "setup() and forget to call super()?" % cls.__name__
    )

The error message that would be displayed if there is no request attribute gives a good explanation of why this check is necessary - the setup() method could be overridden creating the possibility of the request attribute not being created on the instance. We'll save the "why" and "how" of overriding class methods for a later article, but it is a common occurrence when you are creating your own CBVs or modifying Django's built in generic CBVs.

After this check, view() finally returns by calling the dispatch() method.

Routing the view logic with dispatch()

dispatch() is passed the same three arguments as view() and does exactly what it's name implies - it dispatches the view to the correct logic branch based on the request type.

def dispatch(self, request, *args, **kwargs):
    # Try to dispatch to the right method; if a method doesn't exist,
    # defer to the error handler. Also defer to the error handler if the
    # request method isn't on the approved list.
    if request.method.lower() in self.http_method_names:
        handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
    else:
        handler = self.http_method_not_allowed
    return handler(request, *args, **kwargs)

It takes the HTTP method of the request, checks to make sure it is in the list of allowed methods stored in self.http_method_names and then gets the corresponding method of the same name from self and assigns it to handler. For example, if a GET request is made, handler will be assigned the get() method of the class. Or if a POST request is made, handler will be the post() method of the class. If the request method is not in self.http_method_names or if self doesn't have the corresponding method, then handler gets assigned the http_method_not_allowed method which will return HttpResponseNotAllowed. dispatch() returns by calling whatever method is assigned to handler with the request, *args, and **kwargs arguments.

If we go back and look at the complete code for the View class, we'll notice that there are no get() or post() methods, or methods for most of the other HTTP methods. It only contains a method options() to handle the HTTP OPTIONS method.

So what happens when we need to handle a GET request?

At this point, we have reached the limit of the View class. View is not meant as a standalone CBV. It's used as a base class from which other CBVs inherit. Classes that inherit from View will define get(), post(), and other methods necessary to handle the request. If we look again at RedirectView we can see how it defines methods for all the HTTP method names.

# Inside RedirectView
def get(self, request, *args, **kwargs):
        url = self.get_redirect_url(*args, **kwargs)
        if url:
            if self.permanent:
                return HttpResponsePermanentRedirect(url)
            else:
                return HttpResponseRedirect(url)
        else:
            logger.warning(
                'Gone: %s', request.path,
                extra={'status_code': 410, 'request': request}
            )
            return HttpResponseGone()

def head(self, request, *args, **kwargs):
    return self.get(request, *args, **kwargs)

def post(self, request, *args, **kwargs):
    return self.get(request, *args, **kwargs)

def options(self, request, *args, **kwargs):
    return self.get(request, *args, **kwargs)

def delete(self, request, *args, **kwargs):
    return self.get(request, *args, **kwargs)

def put(self, request, *args, **kwargs):
    return self.get(request, *args, **kwargs)

def patch(self, request, *args, **kwargs):
    return self.get(request, *args, **kwargs)

Other generic CBVs or custom CBVs that you create yourself need to have methods defined to handle the HTTP methods that you expect to be requested of the view.

Summary

View has a lot going on and it isn't even a complete view, so let's recap what we've gone over.

  • The View class sets up and kicks off the chain of events needed to properly handle the logic of a CBV
  • Calling the as_view() method on the view in the URLconf returns the view() function which Django will use to handle the request
  • When Django calls view(), an instance of the class is created and the dispatch() method is called
  • dispatch() routes to the appropriate method based on the request's HTTP method
  • View is not a complete view but a base from which other CBVs inherit

In future articles, we'll get into the details of using class-based views, how to create your own, and the power of Django's built-in generic CBVs.

Helpful Resources

Questions, comments, still trying to figure it out? - Let me know!