ZetCode

Django caching

last modified November 13, 2026 Python 3.15

A cache keeps the result of expensive work, a database query, a rendered template, a whole response, so that the next request can use it without repeating the work. Django has a cache framework with a uniform API and several backends, from a dictionary in the memory of the process to Redis and Memcached. The cache can be used at three levels: for single values, for a view or a template fragment, and for the entire site. In this article we configure the backends, use the low-level API, cache views and fragments, switch on the per-site cache, deal with invalidation, and test the code. The examples use Django 6.1 and Python 3.15.

The example project

The project is created with uv; the steps are explained in Django introduction. The commands below create a project directory, install Django and redis, pymemcache, generate the project with the configuration package config, start the app catalog, and create the folder demos for demo scripts.

$ mkdir caching
$ cd caching
$ uv init --bare
$ uv add django redis pymemcache
$ uv run django-admin startproject config .
$ uv run python manage.py startapp catalog
$ mkdir demos

The trailing dot in startproject config . creates manage.py and the config package in the current directory instead of one level deeper. A new app is ignored by Django until it is listed in INSTALLED_APPS, so we add the app to the list in config/settings.py; the other entries are generated by startproject.

config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'catalog',
]

The listings in this article are of two kinds. A listing with a solid blue border and the label Project file is a file of the application: models, views, templates, tests, and settings. A listing with a dashed amber border and the label Demo script is a small helper in the demos folder; it sets up Django, runs a few lines of code, and prints the results, so that we can watch the behaviour. Demo scripts are not part of the application; we run one from the project directory with uv run python -m demos.name.

The packages redis and pymemcache are the client libraries for the two network backends; Django ships the backend classes but not the clients. The app catalog has one model, Product. The functions at the end of the module are signal receivers: after a product is saved or deleted they remove the cached data that depends on products. We come back to them in the section on invalidation.

catalog/models.py
from django.core.cache import cache
from django.core.cache.utils import make_template_fragment_key
from django.db import models
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver


class Product(models.Model):
    name = models.CharField(max_length=60)
    price = models.DecimalField(max_digits=8, decimal_places=2)

    class Meta:
        ordering = ['-price']

    def __str__(self):
        return self.name


@receiver([post_save, post_delete], sender=Product)
def invalidate_product_caches(sender, **kwargs):
    cache.delete('product_count')
    cache.delete(make_template_fragment_key('top_products'))

The model has a name and a price, and it is ordered by price. We create and apply the migration, and the script seed.py adds five products.

$ uv run python manage.py makemigrations catalog
No changes detected in app 'catalog'
$ uv run python manage.py migrate | tail -n 2
Running migrations:
  No migrations to apply.
demos/seed.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from catalog.models import Product

Product.objects.all().delete()
for name, price in [('Pen', '1.20'), ('Notebook', '4.50'), ('Lamp', '19.90'),
                    ('Chair', '89.00'), ('Desk', '249.00')]:
    Product.objects.create(name=name, price=price)
print('products:', Product.objects.count())

The script deletes the existing products first, so it can be run again.

$ uv run python -m demos.seed
products: 5

Cache backends

The setting CACHES is a dictionary of named caches; default is the one used by the functions that do not name a cache. Without the setting Django uses a local-memory cache. Each entry names a backend class and a LOCATION, whose meaning depends on the backend. We define one cache of each kind: LocMemCache keeps values in the memory of the process, which is fast and needs no setup but is not shared between processes and is lost on restart; FileBasedCache stores one file per key in a directory; DatabaseCache uses a table; RedisCache and PyMemcacheCache talk to servers that are shared by all processes and machines of the application. In production the last two are the usual choices.

config/settings.py (caches)
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
        'LOCATION': 'default',
    },
    'file': {
        'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
        'LOCATION': BASE_DIR / 'cache_files',
    },
    'db': {
        'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
        'LOCATION': 'django_cache',
    },
    'redis': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': 'redis://127.0.0.1:6380',
    },
    'memcached': {
        'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache',
        'LOCATION': '127.0.0.1:11212',
    },
}

The Redis and Memcached servers run in Docker containers here; the ports are shifted so that they do not collide with servers that may run on the machine. The database cache needs its table, which the command createcachetable creates.

$ docker run --rm -d --name zc-redis -p 6380:6379 redis:8
$ docker run --rm -d --name zc-memcached -p 11212:11211 memcached:1.6

The first two commands start the servers; the containers are removed when they stop. The third command creates the table django_cache.

$ uv run python manage.py createcachetable
Cache table 'django_cache' already exists.

The same code works with every backend. The script walks through the aliases, stores a dictionary and a counter in each cache, and reads them back; then it looks at where the data landed: the files, the rows of the table, and the keys in Redis.

demos/backends.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

import time
from pathlib import Path

import redis
from django.conf import settings
from django.core.cache import caches
from django.db import connection

for alias in settings.CACHES:
    cache = caches[alias]
    cache.clear()
    start = time.perf_counter()
    cache.set('greeting', {'text': 'hello'}, 300)
    value = cache.get('greeting')
    cache.set('counter', 0)
    cache.incr('counter')
    elapsed = (time.perf_counter() - start) * 1000
    backend = settings.CACHES[alias]['BACKEND'].rsplit('.', 2)
    print(f'{alias:<10} {backend[-2] + "." + backend[-1]:<28} get -> {value}, counter -> {cache.get("counter")}')

print()
files = list(Path(settings.CACHES['file']['LOCATION']).iterdir())
print('file :', len(files), 'files', sorted({path.suffix for path in files}))
with connection.cursor() as cursor:
    cursor.execute('SELECT cache_key, substr(value, 1, 12) FROM django_cache')
    print('db   :', cursor.fetchall())
print('redis:', sorted(key.decode() for key in redis.Redis(port=6380).keys('*')))
print('timeout=300 -> ttl in Redis:', redis.Redis(port=6380).ttl(':1:greeting'), 'seconds')

All five caches give the same answers. Django builds the key from the key prefix, the version, and the name, so the key greeting is stored as :1:greeting (the prefix is empty by default). The file cache has one file per key, the database cache one row per key with the pickled value in a column, and Redis has the keys with the time to live that we passed, 300 seconds.

$ uv run python -m demos.backends
default    locmem.LocMemCache           get -> {'text': 'hello'}, counter -> 1
file       filebased.FileBasedCache     get -> {'text': 'hello'}, counter -> 1
db         db.DatabaseCache             get -> {'text': 'hello'}, counter -> 1
redis      redis.RedisCache             get -> {'text': 'hello'}, counter -> 1
memcached  memcached.PyMemcacheCache    get -> {'text': 'hello'}, counter -> 1

file : 2 files ['.djcache']
db   : [(':1:greeting', 'gAWVEwAAAAAA'), (':1:counter', 'gAVLAS4=')]
redis: [':1:counter', ':1:greeting']
timeout=300 -> ttl in Redis: 300 seconds

The low-level cache API

The object django.core.cache.cache is a proxy to the default cache; caches['name'] returns another one. The main methods are set(), get() with an optional default, add(), which stores a value only if the key does not exist yet, get_or_set(), which takes a value or a callable, delete(), incr() and decr(), the bulk methods get_many(), set_many(), and delete_many(), touch() for renewing the lifetime, and clear(). The argument timeout is in seconds: None means forever and 0 means do not store. The default is 300 seconds, set by TIMEOUT in the entry of CACHES. The argument version makes it possible to keep several generations of a key.

demos/lowlevel.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

import time

from django.core.cache import cache

cache.clear()
cache.set('greeting', 'hello', timeout=30)
print('get:', cache.get('greeting'))
print('missing key:', cache.get('nothing'), '| with default:', cache.get('nothing', 'n/a'))
print('add existing key:', cache.add('greeting', 'changed'), '->', cache.get('greeting'))
print('add new key:', cache.add('fresh', 1), '->', cache.get('fresh'))
print('in:', 'greeting' in cache)
print()

cache.set('visits', 0)
print('incr:', cache.incr('visits'), cache.incr('visits', 5), '| decr:', cache.decr('visits'))
print('get_many:', cache.get_many(['greeting', 'visits', 'nothing']))
cache.set_many({'a': 1, 'b': 2, 'c': 3})
cache.delete_many(['a', 'b'])
print('after delete_many:', cache.get_many(['a', 'b', 'c']))
print()

calls = []


def expensive():
    calls.append(1)
    return 42


print('get_or_set:', cache.get_or_set('answer', expensive, 60), cache.get_or_set('answer', expensive, 60),
      '| function called', len(calls), 'time(s)')
print()

cache.set('short', 'x', timeout=1)
cache.set('forever', 'x', timeout=None)
cache.set('now', 'x', timeout=0)
print('timeout=0 stores nothing:', cache.get('now'))
time.sleep(1.2)
print('after 1.2 s: short =', cache.get('short'), '| forever =', cache.get('forever'))
cache.set('touch', 'x', timeout=1)
cache.touch('touch', timeout=60)
time.sleep(1.2)
print('touch() extended the key:', cache.get('touch'))
print()

original = ['a', 'b']
cache.set('list', original)
original.append('c')
fetched = cache.get('list')
fetched.append('d')
print('values are pickled copies:', original, fetched, cache.get('list'))

cache.set('page', 'version one', version=1)
cache.set('page', 'version two', version=2)
print('versions:', cache.get('page', version=1), '|', cache.get('page', version=2), '| default version:', cache.get('page'))
print('make_key:', cache.make_key('page'), '| version 2:', cache.make_key('page', version=2))
cache.clear()
print('after clear():', cache.get('greeting'))

add() returns False and leaves the old value when the key exists. get_or_set() calls the function only the first time. Expiration is lazy: after 1.2 seconds the key with the timeout of one second is gone, while touch() saved the other one. Note that the cache stores a pickled copy: changing the list that we put there, or the list that we got back, does not change the cached value. Versions are separate keys, :1:page and :2:page. Do not rely on the cache for data that must not be lost; it may drop any key at any time, so the code must always be able to compute the value again.

$ uv run python -m demos.lowlevel
get: hello
missing key: None | with default: n/a
add existing key: False -> hello
add new key: True -> 1
in: True

incr: 1 6 | decr: 5
get_many: {'greeting': 'hello', 'visits': 5}
after delete_many: {'c': 3}

get_or_set: 42 42 | function called 1 time(s)

timeout=0 stores nothing: None
after 1.2 s: short = None | forever = x
touch() extended the key: x

values are pickled copies: ['a', 'b', 'c'] ['a', 'b', 'd'] ['a', 'b']
versions: version one | version two | default version: version one
make_key: :1:page | version 2: :2:page
after clear(): None

Caching views

The decorator cache_page(seconds) caches the whole response of a view, for GET and HEAD requests with the status 200. The key is made of the URL, including the query string, and of the headers named in the Vary header of the response. The decorator also sets Cache-Control: max-age, so browsers and proxies may cache the page too. If the page differs for different visitors, it has to say so: vary_on_cookie (or vary_on_headers) adds the header Vary, and the cache then keeps one copy per cookie value. A page that shows personal data without it would be shown to the wrong person. The decorator never_cache does the opposite and tells the clients not to keep the response. The views of the example count how many times they were built, so that the cache hits are visible. The remaining views are used in the later sections: product_count caches a number with get_or_set() and product_list renders a template with a cached fragment.

catalog/views.py
from django.core.cache import cache
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render
from django.views.decorators.cache import cache_page, never_cache
from django.views.decorators.vary import vary_on_cookie

from .models import Product

CALLS = {'report': 0, 'greeting': 0, 'plain': 0}


@cache_page(60)
def report(request):
    CALLS['report'] += 1
    return HttpResponse(f'The report was built {CALLS["report"]} time(s)')


@cache_page(60)
@vary_on_cookie
def greeting(request):
    CALLS['greeting'] += 1
    name = request.COOKIES.get('name', 'stranger')
    return HttpResponse(f'Hello, {name} (built {CALLS["greeting"]} time(s))')


def plain(request):
    CALLS['plain'] += 1
    return HttpResponse(f'The page was built {CALLS["plain"]} time(s)')


@never_cache
def clock(request):
    return HttpResponse('never cached')


def product_count(request):
    count = cache.get_or_set('product_count', lambda: Product.objects.count(), 300)
    return JsonResponse({'count': count})


def product_list(request):
    return render(request, 'catalog/product_list.html', {
        'products': Product.objects.all(),
        'top_products': Product.objects.all()[:3],
    })

The URL patterns map the paths to the views, and the project includes them at the root.

catalog/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('report/', views.report),
    path('greeting/', views.greeting),
    path('plain/', views.plain),
    path('clock/', views.clock),
    path('count/', views.product_count),
    path('products/', views.product_list),
]

The root URL configuration includes the URLs of the app.

config/urls.py
from django.urls import include, path

urlpatterns = [
    path('', include('catalog.urls')),
]

The script visits the views with the test client and prints what the views built, the headers, and the number of queries.

demos/views.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

import logging

from django.core.cache import cache
from django.db import connection
from django.test.utils import CaptureQueriesContext

from catalog import views

logging.getLogger('django.request').setLevel(logging.ERROR)
cache.clear()
client = Client()


def headers(response):
    return {name: response[name] for name in ('Cache-Control', 'Vary') if name in response}


print('cache_page(60)')
for number in (1, 2):
    response = client.get('/report/')
    print(f'  request {number}:', response.content.decode(), headers(response) if number == 1 else '')
print('  with a query string:', client.get('/report/?page=2').content.decode())
print()

print('cache_page + vary_on_cookie')
for cookie in ('anna', 'ben', 'anna', None):
    other = Client()
    if cookie:
        other.cookies['name'] = cookie
    response = other.get('/greeting/')
    print(f'  cookie {cookie!s:<5}:', response.content.decode(), '| Vary:', response['Vary'])
print()

response = client.get('/clock/')
print('never_cache:', headers(response))
print()

print('get_or_set in a view')
for number in (1, 2):
    with CaptureQueriesContext(connection) as queries:
        data = client.get('/count/').json()
    print(f'  request {number}:', data, '| queries:', len(queries))

The second request for /report/ is served from the cache: the counter did not change. A different query string is a different page. With vary_on_cookie the pages for anna and ben are separate, the repeated request for anna is a hit, and a visitor without the cookie gets a third copy. The response of never_cache carries the directives that forbid caching. The view /count/ runs the query once; the second request needs no query at all.

$ uv run python -m demos.views
cache_page(60)
  request 1: The report was built 1 time(s) {'Cache-Control': 'max-age=60'}
  request 2: The report was built 1 time(s) 
  with a query string: The report was built 2 time(s)

cache_page + vary_on_cookie
  cookie anna : Hello, anna (built 1 time(s)) | Vary: Cookie
  cookie ben  : Hello, ben (built 2 time(s)) | Vary: Cookie
  cookie anna : Hello, anna (built 1 time(s)) | Vary: Cookie
  cookie None : Hello, stranger (built 3 time(s)) | Vary: Cookie

never_cache: {'Cache-Control': 'max-age=0, no-cache, no-store, must-revalidate, private'}

get_or_set in a view
  request 1: {'count': 5} | queries: 1
  request 2: {'count': 5} | queries: 0

Caching template fragments and invalidation

Sometimes only a part of the page is expensive. The tag {% cache seconds name %} from the library cache caches the rendered text of its block. Additional arguments after the name, such as a user id, make separate fragments. The template below shows a list of top products. The list is a lazy QuerySet in the context, so the database is queried only when the block has to be rendered, which makes the saving visible.

catalog/templates/catalog/product_list.html
{% load cache %}
<h1>Products</h1>
{% cache 300 top_products %}
<h2>Top products</h2>
<ul>
  {% for product in top_products %}
  <li>{{ product.name }}: {{ product.price }}</li>
  {% endfor %}
</ul>
{% endcache %}
<p>All products: {{ products|length }}</p>

The hardest part of caching is knowing when to throw the data away. The simplest strategy is expiration, accepting that the data may be up to 300 seconds old. When that is too long, the code that changes the data deletes the cache entries that depend on it. The receivers in catalog/models.py do this after each save and delete of a product: they delete the counter and the fragment, whose key is rebuilt with make_template_fragment_key(). We use signals rather than overriding save() and delete(), because the QuerySet method delete() does not call the delete() of the model, but it sends the signals. The script below shows the cycle.

demos/fragment.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

from django.core.cache import cache
from django.core.cache.utils import make_template_fragment_key
from django.db import connection
from django.test.utils import CaptureQueriesContext

from catalog.models import Product

cache.clear()
client = Client()
print('fragment key:', make_template_fragment_key('top_products'))
print()


def visit(label):
    with CaptureQueriesContext(connection) as queries:
        html = client.get('/products/').content.decode()
    names = [line.strip() for line in html.splitlines() if line.strip().startswith('<li>')]
    print(f'{label:<24} queries: {len(queries)} | {names[0]} ... ({len(names)} items)')


visit('first request')
visit('second request')
Product.objects.create(name='Monitor', price='199.00')
visit('after a new product')
visit('next request')
Product.objects.filter(name='Monitor').delete()
visit('after a queryset delete')
visit('next request')
Product.objects.filter(name='Pen').update(price='1.30')
print('the key is still there after update():', cache.get(make_template_fragment_key('top_products')) is not None)

The first request needs two queries, one for the fragment and one for the count of all products. The next ones need only the second. Creating a product or deleting products with a QuerySet invalidates the fragment, so the next request builds it again. The last line shows the limit of the approach: update(), like bulk_create() and bulk_update(), does not send any signal, so a code that uses them has to invalidate the cache itself.

$ uv run python -m demos.fragment
fragment key: template.cache.top_products.d41d8cd98f00b204e9800998ecf8427e

first request            queries: 2 | <li>Desk: 249.00</li> ... (3 items)
second request           queries: 1 | <li>Desk: 249.00</li> ... (3 items)
after a new product      queries: 2 | <li>Desk: 249.00</li> ... (3 items)
next request             queries: 1 | <li>Desk: 249.00</li> ... (3 items)
after a queryset delete  queries: 2 | <li>Desk: 249.00</li> ... (3 items)
next request             queries: 1 | <li>Desk: 249.00</li> ... (3 items)
the key is still there after update(): True

The per-site cache

The per-site cache caches every page of the site with two middleware classes. UpdateCacheMiddleware must be first in MIDDLEWARE and stores the response on the way out; FetchFromCacheMiddleware must be last and answers from the cache on the way in. The settings CACHE_MIDDLEWARE_SECONDS, CACHE_MIDDLEWARE_KEY_PREFIX, and CACHE_MIDDLEWARE_ALIAS configure it. Only GET and HEAD responses with the status 200 are stored, and the middleware respects Vary and Cache-Control: private. (The order of middleware is described in Django middleware.) The script switches the middleware on for a moment; the middleware reads its settings when it is created, so the script uses a new test client.

demos/sitecache.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

from django.core.cache import cache
from django.test import override_settings

from catalog import views

MIDDLEWARE = [
    'django.middleware.cache.UpdateCacheMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.cache.FetchFromCacheMiddleware',
]

cache.clear()
views.CALLS['plain'] = 0
with override_settings(MIDDLEWARE=MIDDLEWARE, CACHE_MIDDLEWARE_SECONDS=120,
                       CACHE_MIDDLEWARE_KEY_PREFIX='site'):
    client = Client()
    for path in ('/clock/', '/clock/', '/plain/', '/plain/', '/plain/?x=1'):
        response = client.get(path)
        print(f'GET {path:<12}', response.content.decode(), '|', response.get('Cache-Control'))
    for number in (1, 2):
        print('POST /plain/ ->', client.post('/plain/').content.decode())

The page /plain/ has no caching code of its own, yet the second request does not build it again. The query string makes a new page. The view with never_cache is not stored, and POST requests always reach the view.

$ uv run python -m demos.sitecache
GET /clock/      never cached | max-age=0, no-cache, no-store, must-revalidate, private
GET /clock/      never cached | max-age=0, no-cache, no-store, must-revalidate, private
GET /plain/      The page was built 1 time(s) | max-age=120
GET /plain/      The page was built 1 time(s) | max-age=120
GET /plain/?x=1  The page was built 2 time(s) | max-age=120
POST /plain/ -> The page was built 3 time(s)
POST /plain/ -> The page was built 4 time(s)

Testing caching

Tests should not depend on the state of a real cache, so the test class replaces CACHES with a local-memory cache and clears it before each test. The tests count the calls of the views, check the headers, and verify that creating a product invalidates the cached count and the fragment.

catalog/tests.py
from django.core.cache import cache
from django.test import TestCase, override_settings

from . import views
from .models import Product


@override_settings(CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}})
class CacheTests(TestCase):

    def setUp(self):
        cache.clear()
        views.CALLS.update(report=0, greeting=0, plain=0)

    def test_cache_page(self):
        first = self.client.get('/report/')
        second = self.client.get('/report/')
        self.assertEqual(first.content, second.content)
        self.assertEqual(views.CALLS['report'], 1)
        self.assertIn('max-age=60', first['Cache-Control'])

    def test_vary_on_cookie(self):
        self.client.cookies['name'] = 'anna'
        self.assertContains(self.client.get('/greeting/'), 'Hello, anna')
        self.client.cookies['name'] = 'ben'
        self.assertContains(self.client.get('/greeting/'), 'Hello, ben')
        self.assertEqual(views.CALLS['greeting'], 2)

    def test_never_cache(self):
        self.assertIn('no-cache', self.client.get('/clock/')['Cache-Control'])

    def test_count_is_cached_and_invalidated(self):
        Product.objects.create(name='Pen', price='1.20')
        self.assertEqual(self.client.get('/count/').json(), {'count': 1})
        with self.assertNumQueries(0):
            self.assertEqual(self.client.get('/count/').json(), {'count': 1})
        Product.objects.create(name='Pad', price='2.00')
        self.assertEqual(self.client.get('/count/').json(), {'count': 2})

    def test_fragment_is_invalidated(self):
        Product.objects.create(name='Pen', price='1.20')
        self.assertContains(self.client.get('/products/'), 'Pen')
        Product.objects.create(name='Lamp', price='19.90')
        self.assertContains(self.client.get('/products/'), 'Lamp')

All five tests pass. The test of the count uses assertNumQueries(0) to prove that the second request is served from the cache. The test does not touch the servers, which can be stopped with docker stop zc-redis zc-memcached.

$ uv run python manage.py test
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.011s

OK
Destroying test database for alias 'default'...

Source

Django documentation: Django's cache framework

Django documentation: CACHES setting

Django documentation: Cache middleware

In this article we have configured five cache backends, used the low-level API, cached views and template fragments, enabled the per-site cache, invalidated entries with signals, and tested the result. Related articles are Django query performance, Django sessions and cookies, and Django middleware.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.