diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 45ce3dc..509d6ac 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,31 @@
 What’s new in django-cachalot?
 ==============================
 
+2.5.1
+-----
+
+- Table invalidation condition enhanced (#213)
+- Add test settings to sdist (#203)
+- Include docs in sdist (#202)
+
+2.5.0
+-----
+
+- Add final SQL check to include potentially overlooked tables when looking up involved tables (#199)
+- Add ``CACHALOT_FINAL_SQL_CHECK`` for enabling Final SQL check
+
+2.4.5
+-----
+
+- Dropped Python 3.6 and Django 3.1 support. Added Django 4.0 support (#208)
+
+2.4.4
+-----
+
+- Handle queryset implementations without lhs/rhs attribute (#204)
+- Add Python 3.10 support (#206)
+- (Internal) Omit additional unnecessary code in coverage
+
 2.4.3
 -----
 
diff --git a/MANIFEST.in b/MANIFEST.in
index 24e4d46..bd50610 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,3 @@
-include README.rst LICENSE CHANGELOG.rst requirements.txt
+include README.rst LICENSE CHANGELOG.rst requirements.txt tox.ini runtests.py runtests_urls.py settings.py
 recursive-include cachalot *.json *.html
+graft docs
diff --git a/PKG-INFO b/PKG-INFO
index 7b4bca9..7b551b3 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,155 +1,11 @@
-Metadata-Version: 1.1
+Metadata-Version: 2.1
 Name: django-cachalot
-Version: 2.4.3
+Version: 2.5.1
 Summary: Caches your Django ORM queries and automatically invalidates them.
 Home-page: https://github.com/noripyt/django-cachalot
 Author: Bertrand Bordage, Andrew Chen Wang
 Author-email: acwangpython@gmail.com
 License: BSD
-Description: Django Cachalot
-        ===============
-        
-        Caches your Django ORM queries and automatically invalidates them.
-        
-        Documentation: http://django-cachalot.readthedocs.io
-        
-        ----
-        
-        .. image:: http://img.shields.io/pypi/v/django-cachalot.svg?style=flat-square&maxAge=3600
-           :target: https://pypi.python.org/pypi/django-cachalot
-        
-        .. image:: https://img.shields.io/pypi/pyversions/django-cachalot
-            :target: https://django-cachalot.readthedocs.io/en/latest/
-        
-        .. image:: https://github.com/noripyt/django-cachalot/actions/workflows/ci.yml/badge.svg
-           :target: https://github.com/noripyt/django-cachalot/actions/workflows/ci.yml
-        
-        .. image:: http://img.shields.io/coveralls/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
-           :target: https://coveralls.io/r/noripyt/django-cachalot?branch=master
-        
-        .. image:: http://img.shields.io/scrutinizer/g/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
-           :target: https://scrutinizer-ci.com/g/noripyt/django-cachalot/
-        
-        .. image:: https://img.shields.io/discord/773656139207802881
-            :target: https://discord.gg/WFGFBk8rSU
-        
-        ----
-        
-        Table of Contents:
-        
-        - Quickstart
-        - Usage
-        - Hacking
-        - Benchmark
-        - Third-Party Cache Comparison
-        - Discussion
-        
-        Quickstart
-        ----------
-        
-        Cachalot officially supports Python 3.6-3.9 and Django 2.2 and 3.1-3.2 with the databases PostgreSQL, SQLite, and MySQL.
-        
-        Note: an upper limit on Django version is set for your safety. Please do not ignore it.
-        
-        Usage
-        -----
-        
-        #. ``pip install django-cachalot``
-        #. Add ``'cachalot',`` to your ``INSTALLED_APPS``
-        #. If you use multiple servers with a common cache server,
-           `double check their clock synchronisation <https://django-cachalot.readthedocs.io/en/latest/limits.html#multiple-servers>`_
-        #. If you modify data outside Django
-           – typically after restoring a SQL database –,
-           use the `manage.py command <https://django-cachalot.readthedocs.io/en/latest/quickstart.html#command>`_
-        #. Be aware of `the few other limits <https://django-cachalot.readthedocs.io/en/latest/limits.html#limits>`_
-        #. If you use
-           `django-debug-toolbar <https://github.com/jazzband/django-debug-toolbar>`_,
-           you can add ``'cachalot.panels.CachalotPanel',``
-           to your ``DEBUG_TOOLBAR_PANELS``
-        #. Enjoy!
-        
-        Hacking
-        -------
-        
-        To start developing, install the requirements
-        and run the tests via tox.
-        
-        Make sure you have the following services:
-        
-        * Memcached
-        * Redis
-        * PostgreSQL
-        * MySQL
-        
-        For setup:
-        
-        #. Install: ``pip install -r requirements/hacking.txt``
-        #. For PostgreSQL: ``CREATE ROLE cachalot LOGIN SUPERUSER;``
-        #. Run: ``tox --current-env`` to run the test suite on your current Python version.
-        #. You can also run specific databases and Django versions: ``tox -e py38-django3.1-postgresql-redis``
-        
-        Benchmark
-        ---------
-        
-        Currently, benchmarks are supported on Linux and Mac/Darwin.
-        You will need a database called "cachalot" on MySQL and PostgreSQL.
-        Additionally, on PostgreSQL, you will need to create a role
-        called "cachalot." You can also run the benchmark, and it'll raise
-        errors with specific instructions for how to fix it.
-        
-        #. Install: ``pip install -r requirements/benchmark.txt``
-        #. Run: ``python benchmark.py``
-        
-        The output will be in benchmark/TODAY'S_DATE/
-        
-        TODO Create Docker-compose file to allow for easier running of data.
-        
-        Third-Party Cache Comparison
-        ----------------------------
-        
-        There are three main third party caches: cachalot, cache-machine, and cache-ops. Which do you use? We suggest a mix:
-        
-        TL;DR Use cachalot for cold or modified <50 times per minutes (Most people should stick with only cachalot since you
-        most likely won't need to scale to the point of needing cache-machine added to the bowl). If you're an enterprise that
-        already has huge statistics, then mixing cold caches for cachalot and your hot caches with cache-machine is the best
-        mix. However, when performing joins with ``select_related`` and ``prefetch_related``, you can
-        get a nearly 100x speed up for your initial deployment.
-        
-        Recall, cachalot caches THE ENTIRE TABLE. That's where its inefficiency stems from: if you keep updating the records,
-        then the cachalot constantly invalidates the table and re-caches. Luckily caching is very efficient, it's just the cache
-        invalidation part that kills all our systems. Look at Note 1 below to see how Reddit deals with it.
-        
-        Cachalot is more-or-less intended for cold caches or "just-right" conditions. If you find a partition library for
-        Django (also authored but work-in-progress by `Andrew Chen Wang`_), then the caching will work better since sharding
-        the cold/accessed-the-least records aren't invalidated as much.
-        
-        Cachalot is good when there are <50 modifications per minute on a hot cached table. This is mostly due to cache invalidation. It's the same with any cache,
-        which is why we suggest you use cache-machine for hot caches. Cache-machine caches individual objects, taking up more in the memory store but
-        invalidates those individual objects instead of the entire table like cachalot.
-        
-        Yes, the bane of our entire existence lies in cache invalidation and naming variables. Why does cachalot suck when
-        stuck with a huge table that's modified rapidly? Since you've mixed your cold (90% of) with your hot (10% of) records,
-        you're caching and invalidating an entire table. It's like trying to boil 1 ton of noodles inside ONE pot instead of
-        100 pots boiling 1 ton of noodles. Which is more efficient? The splitting up of them.
-        
-        Note 1: My personal experience with caches stems from Reddit's: https://redditblog.com/2017/01/17/caching-at-reddit/
-        
-        Note 2: Technical comparison: https://django-cachalot.readthedocs.io/en/latest/introduction.html#comparison-with-similar-tools
-        
-        Discussion
-        ----------
-        
-        Help? Technical chat? `It's here on Discord <https://discord.gg/WFGFBk8rSU>`_.
-        
-        Legacy chats:
-        
-        - https://gitter.im/django-cachalot/Lobby
-        - https://join.slack.com/t/cachalotdjango/shared_invite/zt-dd0tj27b-cIH6VlaSOjAWnTG~II5~qw
-        
-        .. _Andrew Chen Wang: https://github.com/Andrew-Chen-Wang
-        
-        .. image:: https://raw.github.com/noripyt/django-cachalot/master/django-cachalot.jpg
-        
 Platform: UNKNOWN
 Classifier: Development Status :: 5 - Production/Stable
 Classifier: Framework :: Django
@@ -157,11 +13,158 @@ Classifier: Intended Audience :: Developers
 Classifier: License :: OSI Approved :: BSD License
 Classifier: Operating System :: OS Independent
 Classifier: Framework :: Django :: 2.2
-Classifier: Framework :: Django :: 3.1
 Classifier: Framework :: Django :: 3.2
+Classifier: Framework :: Django :: 4.0
 Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.6
 Classifier: Programming Language :: Python :: 3.7
 Classifier: Programming Language :: Python :: 3.8
 Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
 Classifier: Topic :: Internet :: WWW/HTTP
+License-File: LICENSE
+
+Django Cachalot
+===============
+
+Caches your Django ORM queries and automatically invalidates them.
+
+Documentation: http://django-cachalot.readthedocs.io
+
+----
+
+.. image:: http://img.shields.io/pypi/v/django-cachalot.svg?style=flat-square&maxAge=3600
+   :target: https://pypi.python.org/pypi/django-cachalot
+
+.. image:: https://img.shields.io/pypi/pyversions/django-cachalot
+    :target: https://django-cachalot.readthedocs.io/en/latest/
+
+.. image:: https://github.com/noripyt/django-cachalot/actions/workflows/ci.yml/badge.svg
+   :target: https://github.com/noripyt/django-cachalot/actions/workflows/ci.yml
+
+.. image:: http://img.shields.io/coveralls/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
+   :target: https://coveralls.io/r/noripyt/django-cachalot?branch=master
+
+.. image:: http://img.shields.io/scrutinizer/g/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
+   :target: https://scrutinizer-ci.com/g/noripyt/django-cachalot/
+
+.. image:: https://img.shields.io/discord/773656139207802881
+    :target: https://discord.gg/WFGFBk8rSU
+
+----
+
+Table of Contents:
+
+- Quickstart
+- Usage
+- Hacking
+- Benchmark
+- Third-Party Cache Comparison
+- Discussion
+
+Quickstart
+----------
+
+Cachalot officially supports Python 3.7-3.10 and Django 2.2, 3.2, and 4.0 with the databases PostgreSQL, SQLite, and MySQL.
+
+Note: an upper limit on Django version is set for your safety. Please do not ignore it.
+
+Usage
+-----
+
+#. ``pip install django-cachalot``
+#. Add ``'cachalot',`` to your ``INSTALLED_APPS``
+#. If you use multiple servers with a common cache server,
+   `double check their clock synchronisation <https://django-cachalot.readthedocs.io/en/latest/limits.html#multiple-servers>`_
+#. If you modify data outside Django
+   – typically after restoring a SQL database –,
+   use the `manage.py command <https://django-cachalot.readthedocs.io/en/latest/quickstart.html#command>`_
+#. Be aware of `the few other limits <https://django-cachalot.readthedocs.io/en/latest/limits.html#limits>`_
+#. If you use
+   `django-debug-toolbar <https://github.com/jazzband/django-debug-toolbar>`_,
+   you can add ``'cachalot.panels.CachalotPanel',``
+   to your ``DEBUG_TOOLBAR_PANELS``
+#. Enjoy!
+
+Hacking
+-------
+
+To start developing, install the requirements
+and run the tests via tox.
+
+Make sure you have the following services:
+
+* Memcached
+* Redis
+* PostgreSQL
+* MySQL
+
+For setup:
+
+#. Install: ``pip install -r requirements/hacking.txt``
+#. For PostgreSQL: ``CREATE ROLE cachalot LOGIN SUPERUSER;``
+#. Run: ``tox --current-env`` to run the test suite on your current Python version.
+#. You can also run specific databases and Django versions: ``tox -e py38-django3.1-postgresql-redis``
+
+Benchmark
+---------
+
+Currently, benchmarks are supported on Linux and Mac/Darwin.
+You will need a database called "cachalot" on MySQL and PostgreSQL.
+Additionally, on PostgreSQL, you will need to create a role
+called "cachalot." You can also run the benchmark, and it'll raise
+errors with specific instructions for how to fix it.
+
+#. Install: ``pip install -r requirements/benchmark.txt``
+#. Run: ``python benchmark.py``
+
+The output will be in benchmark/TODAY'S_DATE/
+
+TODO Create Docker-compose file to allow for easier running of data.
+
+Third-Party Cache Comparison
+----------------------------
+
+There are three main third party caches: cachalot, cache-machine, and cache-ops. Which do you use? We suggest a mix:
+
+TL;DR Use cachalot for cold or modified <50 times per minutes (Most people should stick with only cachalot since you
+most likely won't need to scale to the point of needing cache-machine added to the bowl). If you're an enterprise that
+already has huge statistics, then mixing cold caches for cachalot and your hot caches with cache-machine is the best
+mix. However, when performing joins with ``select_related`` and ``prefetch_related``, you can
+get a nearly 100x speed up for your initial deployment.
+
+Recall, cachalot caches THE ENTIRE TABLE. That's where its inefficiency stems from: if you keep updating the records,
+then the cachalot constantly invalidates the table and re-caches. Luckily caching is very efficient, it's just the cache
+invalidation part that kills all our systems. Look at Note 1 below to see how Reddit deals with it.
+
+Cachalot is more-or-less intended for cold caches or "just-right" conditions. If you find a partition library for
+Django (also authored but work-in-progress by `Andrew Chen Wang`_), then the caching will work better since sharding
+the cold/accessed-the-least records aren't invalidated as much.
+
+Cachalot is good when there are <50 modifications per minute on a hot cached table. This is mostly due to cache invalidation. It's the same with any cache,
+which is why we suggest you use cache-machine for hot caches. Cache-machine caches individual objects, taking up more in the memory store but
+invalidates those individual objects instead of the entire table like cachalot.
+
+Yes, the bane of our entire existence lies in cache invalidation and naming variables. Why does cachalot suck when
+stuck with a huge table that's modified rapidly? Since you've mixed your cold (90% of) with your hot (10% of) records,
+you're caching and invalidating an entire table. It's like trying to boil 1 ton of noodles inside ONE pot instead of
+100 pots boiling 1 ton of noodles. Which is more efficient? The splitting up of them.
+
+Note 1: My personal experience with caches stems from Reddit's: https://redditblog.com/2017/01/17/caching-at-reddit/
+
+Note 2: Technical comparison: https://django-cachalot.readthedocs.io/en/latest/introduction.html#comparison-with-similar-tools
+
+Discussion
+----------
+
+Help? Technical chat? `It's here on Discord <https://discord.gg/WFGFBk8rSU>`_.
+
+Legacy chats:
+
+- https://gitter.im/django-cachalot/Lobby
+- https://join.slack.com/t/cachalotdjango/shared_invite/zt-dd0tj27b-cIH6VlaSOjAWnTG~II5~qw
+
+.. _Andrew Chen Wang: https://github.com/Andrew-Chen-Wang
+
+.. image:: https://raw.github.com/noripyt/django-cachalot/master/django-cachalot.jpg
+
+
diff --git a/README.rst b/README.rst
index 4ae099b..48fc720 100644
--- a/README.rst
+++ b/README.rst
@@ -39,7 +39,7 @@ Table of Contents:
 Quickstart
 ----------
 
-Cachalot officially supports Python 3.6-3.9 and Django 2.2 and 3.1-3.2 with the databases PostgreSQL, SQLite, and MySQL.
+Cachalot officially supports Python 3.7-3.10 and Django 2.2, 3.2, and 4.0 with the databases PostgreSQL, SQLite, and MySQL.
 
 Note: an upper limit on Django version is set for your safety. Please do not ignore it.
 
diff --git a/cachalot/__init__.py b/cachalot/__init__.py
index 826e376..f8d7ab0 100644
--- a/cachalot/__init__.py
+++ b/cachalot/__init__.py
@@ -1,4 +1,4 @@
-VERSION = (2, 4, 3)
+VERSION = (2, 5, 1)
 __version__ = ".".join(map(str, VERSION))
 
 try:
diff --git a/cachalot/monkey_patch.py b/cachalot/monkey_patch.py
index f152864..60cb9f6 100644
--- a/cachalot/monkey_patch.py
+++ b/cachalot/monkey_patch.py
@@ -1,3 +1,4 @@
+import re
 from collections.abc import Iterable
 from functools import wraps
 from time import time
@@ -21,6 +22,13 @@ from .utils import (
 
 WRITE_COMPILERS = (SQLInsertCompiler, SQLUpdateCompiler, SQLDeleteCompiler)
 
+SQL_DATA_CHANGE_RE = re.compile(
+    '|'.join([
+        fr'(\W|\A){re.escape(keyword)}(\W|\Z)'
+        for keyword in ['update', 'insert', 'delete', 'alter', 'create', 'drop']
+    ]),
+    flags=re.IGNORECASE,
+)
 
 def _unset_raw_connection(original):
     def inner(compiler, *args, **kwargs):
@@ -133,9 +141,7 @@ def _patch_cursor():
                     if isinstance(sql, bytes):
                         sql = sql.decode('utf-8')
                     sql = sql.lower()
-                    if 'update' in sql or 'insert' in sql or 'delete' in sql \
-                            or 'alter' in sql or 'create' in sql \
-                            or 'drop' in sql:
+                    if SQL_DATA_CHANGE_RE.search(sql):
                         tables = filter_cachable(
                             _get_tables_from_sql(connection, sql))
                         if tables:
diff --git a/cachalot/settings.py b/cachalot/settings.py
index 406f06c..a736465 100644
--- a/cachalot/settings.py
+++ b/cachalot/settings.py
@@ -61,6 +61,7 @@ class Settings(object):
     CACHALOT_ADDITIONAL_TABLES = ()
     CACHALOT_QUERY_KEYGEN = 'cachalot.utils.get_query_cache_key'
     CACHALOT_TABLE_KEYGEN = 'cachalot.utils.get_table_cache_key'
+    CACHALOT_FINAL_SQL_CHECK = False
 
     @classmethod
     def add_converter(cls, setting):
diff --git a/cachalot/tests/__init__.py b/cachalot/tests/__init__.py
index 714b869..c3524e7 100644
--- a/cachalot/tests/__init__.py
+++ b/cachalot/tests/__init__.py
@@ -4,7 +4,7 @@ from django.dispatch import receiver
 from ..settings import cachalot_settings
 from .read import ReadTestCase, ParameterTypeTestCase
 from .write import WriteTestCase, DatabaseCommandTestCase
-from .transaction import AtomicTestCase
+from .transaction import AtomicCacheTestCase, AtomicTestCase
 from .thread_safety import ThreadSafetyTestCase
 from .multi_db import MultiDatabaseTestCase
 from .settings import SettingsTestCase
diff --git a/cachalot/tests/migrations/0001_initial.py b/cachalot/tests/migrations/0001_initial.py
index a443496..45b4f87 100644
--- a/cachalot/tests/migrations/0001_initial.py
+++ b/cachalot/tests/migrations/0001_initial.py
@@ -1,3 +1,4 @@
+from django import VERSION as DJANGO_VERSION
 from django.conf import settings
 from django.contrib.postgres.fields import (
     ArrayField, HStoreField, IntegerRangeField,
@@ -10,11 +11,8 @@ from django.db import models, migrations
 def extra_regular_available_fields():
     fields = []
     try:
-        # TODO Add to module import when Dj40 dropped
-        from django import VERSION as DJANGO_VERSION
-        from django.contrib.postgres.fields import JSONField
-        if float(".".join(map(str, DJANGO_VERSION[:2]))) > 3.0:
-            fields.append(('json', JSONField(null=True, blank=True)))
+        from django.db.models import JSONField
+        fields.append(('json', JSONField(null=True, blank=True)))
     except ImportError:
         pass
 
@@ -38,12 +36,10 @@ def extra_postgres_available_fields():
         pass
 
     # Future proofing with Django 40 deprecation
-    try:
+    if DJANGO_VERSION[0] < 4:
         # TODO Remove when Dj40 support is dropped
         from django.contrib.postgres.fields import JSONField
         fields.append(('json', JSONField(null=True, blank=True)))
-    except ImportError:
-        pass
 
     return fields
 
diff --git a/cachalot/tests/models.py b/cachalot/tests/models.py
index 6f9c6ea..8c48640 100644
--- a/cachalot/tests/models.py
+++ b/cachalot/tests/models.py
@@ -1,3 +1,4 @@
+from django import VERSION as DJANGO_VERSION
 from django.conf import settings
 from django.contrib.postgres.fields import (
     ArrayField, HStoreField,
@@ -44,6 +45,10 @@ class TestParent(Model):
 
 
 class TestChild(TestParent):
+    """
+    A OneToOneField to TestParent is automatically added here.
+    https://docs.djangoproject.com/en/3.2/topics/db/models/#multi-table-inheritance
+    """
     public = BooleanField(default=False)
     permissions = ManyToManyField('auth.Permission', blank=True)
 
@@ -53,11 +58,9 @@ class PostgresModel(Model):
                            null=True, blank=True)
 
     hstore = HStoreField(null=True, blank=True)
-    try:
+    if DJANGO_VERSION[0] < 4:
         from django.contrib.postgres.fields import JSONField
         json = JSONField(null=True, blank=True)
-    except ImportError:
-        pass
 
     int_range = IntegerRangeField(null=True, blank=True)
     try:
diff --git a/cachalot/tests/postgres.py b/cachalot/tests/postgres.py
index fe4f073..b7b18c4 100644
--- a/cachalot/tests/postgres.py
+++ b/cachalot/tests/postgres.py
@@ -5,17 +5,18 @@ from unittest import skipUnless
 from django.contrib.postgres.functions import TransactionNow
 from django.db import connection
 from django.test import TransactionTestCase, override_settings
-from psycopg2.extras import NumericRange, DateRange, DateTimeTZRange
+from psycopg2.extras import DateRange, DateTimeTZRange, NumericRange
 from pytz import timezone
 
 from ..utils import UncachableQuery
 from .api import invalidate
 from .models import PostgresModel, Test
 from .test_utils import TestUtilsMixin
-
+from .tests_decorators import all_final_sql_checks, no_final_sql_check, with_final_sql_check
 
 # FIXME: Add tests for aggregations.
 
+
 def is_pg_field_available(name):
     fields = []
     try:
@@ -91,14 +92,18 @@ class PostgresReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.obj1.save()
         self.obj2.save()
 
+    @all_final_sql_checks
     def test_unaccent(self):
-        Test.objects.create(name='Clémentine')
-        Test.objects.create(name='Clementine')
+        obj1 = Test.objects.create(name='Clémentine')
+        obj2 = Test.objects.create(name='Clementine')
         qs = (Test.objects.filter(name__unaccent='Clémentine')
               .values_list('name', flat=True))
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs, ['Clementine', 'Clémentine'])
+        obj1.delete()
+        obj2.delete()
 
+    @all_final_sql_checks
     def test_int_array(self):
         with self.assertNumQueries(1):
             data1 = [o.int_array for o in PostgresModel.objects.all()]
@@ -145,6 +150,7 @@ class PostgresReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, PostgresModel)
         self.assert_query_cached(qs, [[1, 2, 3]])
 
+    @all_final_sql_checks
     def test_hstore(self):
         with self.assertNumQueries(1):
             data1 = [o.hstore for o in PostgresModel.objects.all()]
@@ -198,6 +204,7 @@ class PostgresReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, PostgresModel)
         self.assert_query_cached(qs, [{'a': '1', 'b': '2'}])
 
+    @all_final_sql_checks
     @skipUnless(is_pg_field_available("JSONField"),
                 "JSONField was removed in Dj 4.0")
     def test_json(self):
@@ -309,6 +316,7 @@ class PostgresReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assertListEqual(list(qs.all()),
                              [self.obj1.json, self.obj2.json])
 
+    @all_final_sql_checks
     def test_int_range(self):
         with self.assertNumQueries(1):
             data1 = [o.int_range for o in PostgresModel.objects.all()]
@@ -378,13 +386,16 @@ class PostgresReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, PostgresModel)
         self.assert_query_cached(qs, [NumericRange(1900, 2000)])
 
-        PostgresModel.objects.create(int_range=[1900, 1900])
+        obj = PostgresModel.objects.create(int_range=[1900, 1900])
 
         qs = (PostgresModel.objects.filter(int_range__isempty=True)
               .values_list('int_range', flat=True))
         self.assert_tables(qs, PostgresModel)
         self.assert_query_cached(qs, [NumericRange(empty=True)])
 
+        obj.delete()
+
+    @all_final_sql_checks
     @skipUnless(is_pg_field_available("FloatRangeField"),
                 "FloatRangeField was removed in Dj 3.1")
     def test_float_range(self):
@@ -398,6 +409,7 @@ class PostgresReadTestCase(TestUtilsMixin, TransactionTestCase):
             NumericRange(Decimal('-1000.0'), Decimal('9.87654321')),
             NumericRange(Decimal('0.0'))])
 
+    @all_final_sql_checks
     @skipUnless(is_pg_field_available("DecimalRangeField"),
                 "DecimalRangeField was added in Dj 2.2")
     def test_decimal_range(self):
@@ -407,6 +419,7 @@ class PostgresReadTestCase(TestUtilsMixin, TransactionTestCase):
             NumericRange(Decimal('-1000.0'), Decimal('9.87654321')),
             NumericRange(Decimal('0.0'))])
 
+    @all_final_sql_checks
     def test_date_range(self):
         qs = PostgresModel.objects.values_list('date_range', flat=True)
         self.assert_tables(qs, PostgresModel)
@@ -414,6 +427,7 @@ class PostgresReadTestCase(TestUtilsMixin, TransactionTestCase):
             DateRange(date(1678, 3, 4), date(1741, 7, 28)),
             DateRange(date(1989, 1, 30))])
 
+    @all_final_sql_checks
     def test_datetime_range(self):
         qs = PostgresModel.objects.values_list('datetime_range', flat=True)
         self.assert_tables(qs, PostgresModel)
@@ -422,6 +436,7 @@ class PostgresReadTestCase(TestUtilsMixin, TransactionTestCase):
                                      tzinfo=timezone('Europe/Paris'))),
             DateTimeTZRange(bounds='()')])
 
+    @all_final_sql_checks
     def test_transaction_now(self):
         """
         Checks that queries with a TransactionNow() parameter are not cached.
@@ -431,3 +446,5 @@ class PostgresReadTestCase(TestUtilsMixin, TransactionTestCase):
         with self.assertRaises(UncachableQuery):
             self.assert_tables(qs, Test)
         self.assert_query_cached(qs, [obj], after=1)
+
+        obj.delete()
diff --git a/cachalot/tests/read.py b/cachalot/tests/read.py
index a46effe..8d69956 100644
--- a/cachalot/tests/read.py
+++ b/cachalot/tests/read.py
@@ -4,6 +4,7 @@ from uuid import UUID
 from decimal import Decimal
 
 from django import VERSION as django_version
+from django.conf import settings
 from django.contrib.auth.models import Group, Permission, User
 from django.contrib.contenttypes.models import ContentType
 from django.db import (
@@ -22,6 +23,8 @@ from ..utils import UncachableQuery
 from .models import Test, TestChild, TestParent, UnmanagedModel
 from .test_utils import TestUtilsMixin
 
+from .tests_decorators import all_final_sql_checks, with_final_sql_check, no_final_sql_check
+
 
 def is_field_available(name):
     fields = []
@@ -125,6 +128,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assertListEqual(data2, data1)
         self.assertListEqual(data2, [self.t1, self.t2])
 
+    @all_final_sql_checks
     def test_filter(self):
         qs = Test.objects.filter(public=True)
         self.assert_tables(qs, Test)
@@ -142,11 +146,13 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs, [self.t1])
 
+    @all_final_sql_checks
     def test_filter_empty(self):
         qs = Test.objects.filter(public=True, name='user')
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs, [])
 
+    @all_final_sql_checks
     def test_exclude(self):
         qs = Test.objects.exclude(public=True)
         self.assert_tables(qs, Test)
@@ -156,11 +162,13 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs, [self.t1])
 
+    @all_final_sql_checks
     def test_slicing(self):
         qs = Test.objects.all()[:1]
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs, [self.t1])
 
+    @all_final_sql_checks
     def test_order_by(self):
         qs = Test.objects.order_by('pk')
         self.assert_tables(qs, Test)
@@ -170,12 +178,38 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs, [self.t2, self.t1])
 
+    @all_final_sql_checks
     def test_random_order_by(self):
         qs = Test.objects.order_by('?')
         with self.assertRaises(UncachableQuery):
             self.assert_tables(qs, Test)
         self.assert_query_cached(qs, after=1, compare_results=False)
 
+    @with_final_sql_check
+    def test_order_by_field_of_another_table_with_check(self):
+        qs = Test.objects.order_by('owner__username')
+        self.assert_tables(qs, Test, User)
+        self.assert_query_cached(qs, [self.t2, self.t1])
+
+    @no_final_sql_check
+    def test_order_by_field_of_another_table_no_check(self):
+        qs = Test.objects.order_by('owner__username')
+        self.assert_tables(qs, Test)
+        self.assert_query_cached(qs, [self.t2, self.t1])
+
+    @with_final_sql_check
+    def test_order_by_field_of_another_table_with_expression_with_check(self):
+        qs = Test.objects.order_by(Coalesce('name', 'owner__username'))
+        self.assert_tables(qs, Test, User)
+        self.assert_query_cached(qs, [self.t1, self.t2])
+
+    @no_final_sql_check
+    def test_order_by_field_of_another_table_with_expression_no_check(self):
+        qs = Test.objects.order_by(Coalesce('name', 'owner__username'))
+        self.assert_tables(qs, Test)
+        self.assert_query_cached(qs, [self.t1, self.t2])
+
+    @all_final_sql_checks
     @skipIf(connection.vendor == 'mysql',
             'MySQL does not support limit/offset on a subquery. '
             'Since Django only applies ordering in subqueries when they are '
@@ -187,11 +221,13 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
             self.assert_tables(qs, Test)
         self.assert_query_cached(qs, after=1, compare_results=False)
 
+    @all_final_sql_checks
     def test_reverse(self):
         qs = Test.objects.reverse()
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs, [self.t2, self.t1])
 
+    @all_final_sql_checks
     def test_distinct(self):
         # We ensure that the query without distinct should return duplicate
         # objects, in order to have a real-world example.
@@ -222,12 +258,14 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assertDictEqual(data2, data1)
         self.assertDictEqual(data2, {self.t2.pk: self.t2})
 
+    @all_final_sql_checks
     def test_values(self):
         qs = Test.objects.values('name', 'public')
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs, [{'name': 'test1', 'public': False},
                                       {'name': 'test2', 'public': True}])
 
+    @all_final_sql_checks
     def test_values_list(self):
         qs = Test.objects.values_list('name', flat=True)
         self.assert_tables(qs, Test)
@@ -249,18 +287,21 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assertEqual(data2, data1)
         self.assertEqual(data2, self.t2)
 
+    @all_final_sql_checks
     def test_dates(self):
         qs = Test.objects.dates('date', 'year')
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs, [datetime.date(1789, 1, 1),
                                       datetime.date(1944, 1, 1)])
 
+    @all_final_sql_checks
     def test_datetimes(self):
         qs = Test.objects.datetimes('datetime', 'hour')
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs, [datetime.datetime(1789, 7, 14, 16),
                                       datetime.datetime(1944, 6, 6, 6)])
 
+    @all_final_sql_checks
     @skipIf(connection.vendor == 'mysql',
             'Time zones are not supported by MySQL.')
     @override_settings(USE_TZ=True)
@@ -271,6 +312,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
             datetime.datetime(1789, 7, 14, 16, tzinfo=UTC),
             datetime.datetime(1944, 6, 6, 6, tzinfo=UTC)])
 
+    @all_final_sql_checks
     def test_foreign_key(self):
         with self.assertNumQueries(3):
             data1 = [t.owner for t in Test.objects.all()]
@@ -283,7 +325,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, Test, User)
         self.assert_query_cached(qs, [self.user.pk, self.admin.pk])
 
-    def test_many_to_many(self):
+    def _test_many_to_many(self):
         u = User.objects.create_user('test_user')
         ct = ContentType.objects.get_for_model(User)
         u.user_permissions.add(
@@ -293,50 +335,93 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
                 name='Can touch', content_type=ct, codename='touch'),
             Permission.objects.create(
                 name='Can cuddle', content_type=ct, codename='cuddle'))
-        qs = u.user_permissions.values_list('codename', flat=True)
+        return u.user_permissions.values_list('codename', flat=True)
+
+    @with_final_sql_check
+    def test_many_to_many_when_sql_check(self):
+        qs = self._test_many_to_many()
+        self.assert_tables(qs, User, User.user_permissions.through, Permission, ContentType)
+        self.assert_query_cached(qs, ['cuddle', 'discuss', 'touch'])
+
+    @no_final_sql_check
+    def test_many_to_many_when_no_sql_check(self):
+        qs = self._test_many_to_many()
         self.assert_tables(qs, User, User.user_permissions.through, Permission)
         self.assert_query_cached(qs, ['cuddle', 'discuss', 'touch'])
 
+    @all_final_sql_checks
     def test_subquery(self):
+        additional_tables = []
+        if django_version[0] >= 4 and settings.CACHALOT_FINAL_SQL_CHECK:
+            # with Django 4.0 comes some query optimalizations that do selects little differently.
+            additional_tables.append('django_content_type')
         qs = Test.objects.filter(owner__in=User.objects.all())
         self.assert_tables(qs, Test, User)
         self.assert_query_cached(qs, [self.t1, self.t2])
 
         qs = Test.objects.filter(
-            owner__groups__permissions__in=Permission.objects.all())
-        self.assert_tables(qs, Test, User, User.groups.through, Group,
-                           Group.permissions.through, Permission)
+            owner__groups__permissions__in=Permission.objects.all()
+        )
+        self.assert_tables(
+            qs, Test, User, User.groups.through, Group,
+            Group.permissions.through, Permission,
+            *additional_tables
+        )
         self.assert_query_cached(qs, [self.t1, self.t1, self.t1])
 
         qs = Test.objects.filter(
             owner__groups__permissions__in=Permission.objects.all()
         ).distinct()
-        self.assert_tables(qs, Test, User, User.groups.through, Group,
-                           Group.permissions.through, Permission)
+        self.assert_tables(
+            qs, Test, User, User.groups.through, Group,
+            Group.permissions.through, Permission,
+            *additional_tables
+        )
         self.assert_query_cached(qs, [self.t1])
 
         qs = TestChild.objects.exclude(permissions__isnull=True)
-        self.assert_tables(qs, TestParent, TestChild,
-                           TestChild.permissions.through, Permission)
+        self.assert_tables(
+            qs, TestParent, TestChild,
+            TestChild.permissions.through, Permission
+        )
         self.assert_query_cached(qs, [])
 
         qs = TestChild.objects.exclude(permissions__name='')
-        self.assert_tables(qs, TestParent, TestChild,
-                           TestChild.permissions.through, Permission)
+        self.assert_tables(
+            qs, TestParent, TestChild,
+            TestChild.permissions.through, Permission
+        )
         self.assert_query_cached(qs, [])
 
-    def test_custom_subquery(self):
+    @with_final_sql_check
+    def test_custom_subquery_with_check(self):
+        tests = Test.objects.filter(permission=OuterRef('pk')).values('name')
+        qs = Permission.objects.annotate(first_permission=Subquery(tests[:1]))
+        self.assert_tables(qs, Permission, Test, ContentType)
+        self.assert_query_cached(qs, list(Permission.objects.all()))
+
+    @no_final_sql_check
+    def test_custom_subquery_no_check(self):
         tests = Test.objects.filter(permission=OuterRef('pk')).values('name')
         qs = Permission.objects.annotate(first_permission=Subquery(tests[:1]))
         self.assert_tables(qs, Permission, Test)
         self.assert_query_cached(qs, list(Permission.objects.all()))
 
+    @with_final_sql_check
+    def test_custom_subquery_exists(self):
+        tests = Test.objects.filter(permission=OuterRef('pk'))
+        qs = Permission.objects.annotate(has_tests=Exists(tests))
+        self.assert_tables(qs, Permission, Test, ContentType)
+        self.assert_query_cached(qs, list(Permission.objects.all()))
+
+    @no_final_sql_check
     def test_custom_subquery_exists(self):
         tests = Test.objects.filter(permission=OuterRef('pk'))
         qs = Permission.objects.annotate(has_tests=Exists(tests))
         self.assert_tables(qs, Permission, Test)
         self.assert_query_cached(qs, list(Permission.objects.all()))
 
+    @all_final_sql_checks
     def test_raw_subquery(self):
         with self.assertNumQueries(0):
             raw_sql = RawSQL('SELECT id FROM auth_permission WHERE id = %s',
@@ -350,28 +435,34 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, Test, Permission)
         self.assert_query_cached(qs, [self.t1])
 
+    @all_final_sql_checks
     def test_aggregate(self):
-        Test.objects.create(name='test3', owner=self.user)
+        test3 = Test.objects.create(name='test3', owner=self.user)
         with self.assertNumQueries(1):
             n1 = User.objects.aggregate(n=Count('test'))['n']
         with self.assertNumQueries(0):
             n2 = User.objects.aggregate(n=Count('test'))['n']
         self.assertEqual(n2, n1)
         self.assertEqual(n2, 3)
+        test3.delete()
 
+    @all_final_sql_checks
     def test_annotate(self):
-        Test.objects.create(name='test3', owner=self.user)
+        test3 = Test.objects.create(name='test3', owner=self.user)
         qs = (User.objects.annotate(n=Count('test')).order_by('pk')
               .values_list('n', flat=True))
         self.assert_tables(qs, User, Test)
         self.assert_query_cached(qs, [2, 1])
+        test3.delete()
 
+    @all_final_sql_checks
     def test_annotate_subquery(self):
         tests = Test.objects.filter(owner=OuterRef('pk')).values('name')
         qs = User.objects.annotate(first_test=Subquery(tests[:1]))
         self.assert_tables(qs, User, Test)
         self.assert_query_cached(qs, [self.user, self.admin])
 
+    @all_final_sql_checks
     def test_annotate_case_with_when_and_query_in_default(self):
         tests = Test.objects.filter(owner=OuterRef('pk')).values('name')
         qs = User.objects.annotate(
@@ -383,6 +474,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, User, Test)
         self.assert_query_cached(qs, [self.user, self.admin])
 
+    @all_final_sql_checks
     def test_annotate_case_with_when(self):
         tests = Test.objects.filter(owner=OuterRef('pk')).values('name')
         qs = User.objects.annotate(
@@ -394,6 +486,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, User, Test)
         self.assert_query_cached(qs, [self.user, self.admin])
 
+    @all_final_sql_checks
     def test_annotate_coalesce(self):
         tests = Test.objects.filter(owner=OuterRef('pk')).values('name')
         qs = User.objects.annotate(
@@ -405,6 +498,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, User, Test)
         self.assert_query_cached(qs, [self.user, self.admin])
 
+    @all_final_sql_checks
     def test_annotate_raw(self):
         qs = User.objects.annotate(
             perm_id=RawSQL('SELECT id FROM auth_permission WHERE id = %s',
@@ -413,6 +507,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, User, Permission)
         self.assert_query_cached(qs, [self.user, self.admin])
 
+    @all_final_sql_checks
     def test_only(self):
         with self.assertNumQueries(1):
             t1 = Test.objects.only('name').first()
@@ -428,6 +523,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assertEqual(t2.name, t1.name)
         self.assertEqual(t2.public, t1.public)
 
+    @all_final_sql_checks
     def test_defer(self):
         with self.assertNumQueries(1):
             t1 = Test.objects.defer('name').first()
@@ -443,6 +539,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assertEqual(t2.name, t1.name)
         self.assertEqual(t2.public, t1.public)
 
+    @all_final_sql_checks
     def test_select_related(self):
         # Simple select_related
         with self.assertNumQueries(1):
@@ -468,6 +565,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assertEqual(t4, t3)
         self.assertEqual(t4, self.t1)
 
+    @all_final_sql_checks
     def test_prefetch_related(self):
         # Simple prefetch_related
         with self.assertNumQueries(2):
@@ -530,35 +628,74 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assertListEqual(permissions8, permissions7)
         self.assertListEqual(permissions8, self.group__permissions)
 
-    def test_filtered_relation(self):
+    @all_final_sql_checks
+    def test_test_parent(self):
+        child = TestChild.objects.create(name='child')
+        qs = TestChild.objects.filter(name='child')
+        self.assert_query_cached(qs)
+
+        parent = TestParent.objects.all().first()
+        parent.name = 'another name'
+        parent.save()
+
+        child = TestChild.objects.all().first()
+        self.assertEqual(child.name, 'another name')
+
+    def _filtered_relation(self):
+        """
+        Resulting query:
+            SELECT "cachalot_testparent"."id", "cachalot_testparent"."name",
+            "cachalot_testchild"."testparent_ptr_id", "cachalot_testchild"."public"
+            FROM "cachalot_testchild" INNER JOIN "cachalot_testparent" ON
+            ("cachalot_testchild"."testparent_ptr_id" = "cachalot_testparent"."id")
+        """
         from django.db.models import FilteredRelation
 
         qs = TestChild.objects.annotate(
             filtered_permissions=FilteredRelation(
-                'permissions', condition=Q(permissions__pk__gt=1)))
-        self.assert_tables(qs, TestChild)
+                'permissions', condition=Q(permissions__pk__gt=1))
+        )
+        return qs
+
+    def _filtered_relation_common_asserts(self, qs):
         self.assert_query_cached(qs)
 
         values_qs = qs.values('filtered_permissions')
         self.assert_tables(
-            values_qs, TestChild, TestChild.permissions.through, Permission)
+            values_qs, TestParent, TestChild, TestChild.permissions.through, Permission
+        )
         self.assert_query_cached(values_qs)
 
         filtered_qs = qs.filter(filtered_permissions__pk__gt=2)
         self.assert_tables(
-            values_qs, TestChild, TestChild.permissions.through, Permission)
+            values_qs, TestParent, TestChild, TestChild.permissions.through, Permission
+        )
         self.assert_query_cached(filtered_qs)
 
-    @skipUnlessDBFeature('supports_select_union')
-    def test_union(self):
-        qs = (Test.objects.filter(pk__lt=5)
-              | Test.objects.filter(permission__name__contains='a'))
+    @with_final_sql_check
+    def test_filtered_relation_with_check(self):
+        qs = self._filtered_relation()
+        self.assert_tables(qs, TestParent, TestChild)
+        self._filtered_relation_common_asserts(qs)
+
+    @no_final_sql_check
+    def test_filtered_relation_no_check(self):
+        qs = self._filtered_relation()
+        self.assert_tables(qs, TestChild)
+        self._filtered_relation_common_asserts(qs)
+
+    def _test_union(self, check: bool):
+        qs = (
+            Test.objects.filter(pk__lt=5)
+            | Test.objects.filter(permission__name__contains='a')
+        )
         self.assert_tables(qs, Test, Permission)
         self.assert_query_cached(qs)
 
         with self.assertRaisesMessage(
-                AssertionError,
-                'Cannot combine queries on two different base models.'):
+            AssertionError if django_version[0] < 4 else TypeError,
+            'Cannot combine queries on two different base models.'
+        ):
             Test.objects.all() | Permission.objects.all()
 
         qs = Test.objects.filter(pk__lt=5)
@@ -576,19 +713,32 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
             qs = qs.order_by()
             sub_qs = sub_qs.order_by()
         qs = qs.union(sub_qs)
-        self.assert_tables(qs, Test, Permission)
+        tables = {Test, Permission}
+        # Sqlite does not do an ORDER BY django_content_type
+        if not self.is_sqlite and check:
+            tables.add(ContentType)
+        self.assert_tables(qs, *tables)
         with self.assertRaises((ProgrammingError, OperationalError)):
             self.assert_query_cached(qs)
 
-    @skipUnlessDBFeature('supports_select_intersection')
-    def test_intersection(self):
+    @with_final_sql_check
+    @skipUnlessDBFeature('supports_select_union')
+    def test_union_with_sql_check(self):
+        self._test_union(check=True)
+
+    @no_final_sql_check
+    @skipUnlessDBFeature('supports_select_union')
+    def test_union_with_sql_check(self):
+        self._test_union(check=False)
+
+    def _test_intersection(self, check: bool):
         qs = (Test.objects.filter(pk__lt=5)
               & Test.objects.filter(permission__name__contains='a'))
         self.assert_tables(qs, Test, Permission)
         self.assert_query_cached(qs)
 
         with self.assertRaisesMessage(
-                AssertionError,
+                AssertionError if django_version[0] < 4 else TypeError,
                 'Cannot combine queries on two different base models.'):
             Test.objects.all() & Permission.objects.all()
 
@@ -607,12 +757,24 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
             qs = qs.order_by()
             sub_qs = sub_qs.order_by()
         qs = qs.intersection(sub_qs)
-        self.assert_tables(qs, Test, Permission)
+        tables = {Test, Permission}
+        if not self.is_sqlite and check:
+            tables.add(ContentType)
+        self.assert_tables(qs, *tables)
         with self.assertRaises((ProgrammingError, OperationalError)):
             self.assert_query_cached(qs)
 
-    @skipUnlessDBFeature('supports_select_difference')
-    def test_difference(self):
+    @with_final_sql_check
+    @skipUnlessDBFeature('supports_select_intersection')
+    def test_intersection_with_check(self):
+        self._test_intersection(check=True)
+
+    @no_final_sql_check
+    @skipUnlessDBFeature('supports_select_intersection')
+    def test_intersection_with_check(self):
+        self._test_intersection(check=False)
+
+    def _test_difference(self, check: bool):
         qs = Test.objects.filter(pk__lt=5)
         sub_qs = Test.objects.filter(permission__name__contains='a')
         if self.is_sqlite:
@@ -628,10 +790,23 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
             qs = qs.order_by()
             sub_qs = sub_qs.order_by()
         qs = qs.difference(sub_qs)
-        self.assert_tables(qs, Test, Permission)
+        tables = {Test, Permission}
+        if not self.is_sqlite and check:
+            tables.add(ContentType)
+        self.assert_tables(qs, *tables)
         with self.assertRaises((ProgrammingError, OperationalError)):
             self.assert_query_cached(qs)
 
+    @with_final_sql_check
+    @skipUnlessDBFeature('supports_select_difference')
+    def test_difference_with_check(self):
+        self._test_difference(check=True)
+
+    @no_final_sql_check
+    @skipUnlessDBFeature('supports_select_difference')
+    def test_difference_with_check(self):
+        self._test_difference(check=False)
+
     @skipUnlessDBFeature('has_select_for_update')
     def test_select_for_update(self):
         """
@@ -665,6 +840,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
                 self.assertListEqual([t.name for t in data4],
                                      ['test1', 'test2'])
 
+    @all_final_sql_checks
     def test_having(self):
         qs = (User.objects.annotate(n=Count('user_permissions')).filter(n__gte=1))
         self.assert_tables(qs, User, User.user_permissions.through, Permission)
@@ -697,6 +873,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
             self.assertListEqual(data2, [self.t1, self.t2])
             self.assertListEqual([o.username_length for o in data2], [4, 5])
 
+    @all_final_sql_checks
     def test_extra_where(self):
         sql_condition = ("owner_id IN "
                          "(SELECT id FROM auth_user WHERE username = 'admin')")
@@ -704,12 +881,14 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, Test, User)
         self.assert_query_cached(qs, [self.t2])
 
+    @all_final_sql_checks
     def test_extra_tables(self):
         qs = Test.objects.extra(tables=['auth_user'],
                                 select={'extra_id': 'auth_user.id'})
         self.assert_tables(qs, Test, User)
         self.assert_query_cached(qs)
 
+    @all_final_sql_checks
     def test_extra_order_by(self):
         qs = Test.objects.extra(order_by=['-cachalot_test.name'])
         self.assert_tables(qs, Test)
@@ -850,6 +1029,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         self.assertListEqual(data2, data1)
         self.assertListEqual(data2, [(1,), (2,)])
 
+    @all_final_sql_checks
     def test_missing_table_cache_key(self):
         qs = Test.objects.all()
         self.assert_tables(qs, Test)
@@ -861,6 +1041,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
 
         self.assert_query_cached(qs)
 
+    @all_final_sql_checks
     def test_broken_query_cache_value(self):
         """
         In some undetermined cases, cache.get_many return wrong values such
@@ -889,6 +1070,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
             with self.assertRaises(Test.DoesNotExist):
                 Test.objects.get(name='Clémentine')
 
+    @all_final_sql_checks
     def test_unicode_table_name(self):
         """
         Tests if using unicode in table names does not break caching.
@@ -908,6 +1090,7 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         with connection.cursor() as cursor:
             cursor.execute('DROP TABLE %s;' % table_name)
 
+    @all_final_sql_checks
     def test_unmanaged_model(self):
         qs = UnmanagedModel.objects.all()
         self.assert_tables(qs, UnmanagedModel)
@@ -917,9 +1100,10 @@ class ReadTestCase(TestUtilsMixin, TransactionTestCase):
         """Check that queries with a Now() annotation are not cached #193"""
         qs = Test.objects.annotate(now=Now())
         self.assert_query_cached(qs, after=1)
-        
+
 
 class ParameterTypeTestCase(TestUtilsMixin, TransactionTestCase):
+    @all_final_sql_checks
     def test_tuple(self):
         qs = Test.objects.filter(pk__in=(1, 2, 3))
         self.assert_tables(qs, Test)
@@ -929,6 +1113,7 @@ class ParameterTypeTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs)
 
+    @all_final_sql_checks
     def test_list(self):
         qs = Test.objects.filter(pk__in=[1, 2, 3])
         self.assert_tables(qs, Test)
@@ -949,6 +1134,7 @@ class ParameterTypeTestCase(TestUtilsMixin, TransactionTestCase):
         self.assert_tables(qs, Test)
         self.assert_query_cached(qs)
 
+    @all_final_sql_checks
     def test_binary(self):
         """
         Binary data should be cached on PostgreSQL & MySQL, but not on SQLite,
@@ -990,11 +1176,12 @@ class ParameterTypeTestCase(TestUtilsMixin, TransactionTestCase):
         with self.assertNumQueries(0):
             Test.objects.get(a_float=0.123456789)
 
+    @all_final_sql_checks
     def test_decimal(self):
         with self.assertNumQueries(1):
-            Test.objects.create(name='test1', a_decimal=Decimal('123.45'))
+            test1 = Test.objects.create(name='test1', a_decimal=Decimal('123.45'))
         with self.assertNumQueries(1):
-            Test.objects.create(name='test1', a_decimal=Decimal('12.3'))
+            test2 = Test.objects.create(name='test2', a_decimal=Decimal('12.3'))
 
         qs = Test.objects.values_list('a_decimal', flat=True).filter(
             a_decimal__isnull=False).order_by('a_decimal')
@@ -1006,11 +1193,15 @@ class ParameterTypeTestCase(TestUtilsMixin, TransactionTestCase):
         with self.assertNumQueries(0):
             Test.objects.get(a_decimal=Decimal('123.45'))
 
+        test1.delete()
+        test2.delete()
+
+    @all_final_sql_checks
     def test_ipv4_address(self):
         with self.assertNumQueries(1):
-            Test.objects.create(name='test1', ip='127.0.0.1')
+            test1 = Test.objects.create(name='test1', ip='127.0.0.1')
         with self.assertNumQueries(1):
-            Test.objects.create(name='test2', ip='192.168.0.1')
+            test2 = Test.objects.create(name='test2', ip='192.168.0.1')
 
         qs = Test.objects.values_list('ip', flat=True).filter(
             ip__isnull=False).order_by('ip')
@@ -1022,11 +1213,15 @@ class ParameterTypeTestCase(TestUtilsMixin, TransactionTestCase):
         with self.assertNumQueries(0):
             Test.objects.get(ip='127.0.0.1')
 
+        test1.delete()
+        test2.delete()
+
+    @all_final_sql_checks
     def test_ipv6_address(self):
         with self.assertNumQueries(1):
-            Test.objects.create(name='test1', ip='2001:db8:a0b:12f0::1/64')
+            test1 = Test.objects.create(name='test1', ip='2001:db8:a0b:12f0::1/64')
         with self.assertNumQueries(1):
-            Test.objects.create(name='test2', ip='2001:db8:0:85a3::ac1f:8001')
+            test2 = Test.objects.create(name='test2', ip='2001:db8:0:85a3::ac1f:8001')
 
         qs = Test.objects.values_list('ip', flat=True).filter(
             ip__isnull=False).order_by('ip')
@@ -1039,11 +1234,15 @@ class ParameterTypeTestCase(TestUtilsMixin, TransactionTestCase):
         with self.assertNumQueries(0):
             Test.objects.get(ip='2001:db8:0:85a3::ac1f:8001')
 
+        test1.delete()
+        test2.delete()
+
+    @all_final_sql_checks
     def test_duration(self):
         with self.assertNumQueries(1):
-            Test.objects.create(name='test1', duration=datetime.timedelta(30))
+            test1 = Test.objects.create(name='test1', duration=datetime.timedelta(30))
         with self.assertNumQueries(1):
-            Test.objects.create(name='test2', duration=datetime.timedelta(60))
+            test2 = Test.objects.create(name='test2', duration=datetime.timedelta(60))
 
         qs = Test.objects.values_list('duration', flat=True).filter(
             duration__isnull=False).order_by('duration')
@@ -1056,12 +1255,16 @@ class ParameterTypeTestCase(TestUtilsMixin, TransactionTestCase):
         with self.assertNumQueries(0):
             Test.objects.get(duration=datetime.timedelta(30))
 
+        test1.delete()
+        test2.delete()
+
+    @all_final_sql_checks
     def test_uuid(self):
         with self.assertNumQueries(1):
-            Test.objects.create(name='test1',
+            test1 = Test.objects.create(name='test1',
                                 uuid='1cc401b7-09f4-4520-b8d0-c267576d196b')
         with self.assertNumQueries(1):
-            Test.objects.create(name='test2',
+            test2 = Test.objects.create(name='test2',
                                 uuid='ebb3b6e1-1737-4321-93e3-4c35d61ff491')
 
         qs = Test.objects.values_list('uuid', flat=True).filter(
@@ -1076,6 +1279,9 @@ class ParameterTypeTestCase(TestUtilsMixin, TransactionTestCase):
         with self.assertNumQueries(0):
             Test.objects.get(uuid=UUID('1cc401b7-09f4-4520-b8d0-c267576d196b'))
 
+        test1.delete()
+        test2.delete()
+
     def test_now(self):
         """
         Checks that queries with a Now() parameter are not cached.
diff --git a/cachalot/tests/settings.py b/cachalot/tests/settings.py
index 828754b..cd1c117 100644
--- a/cachalot/tests/settings.py
+++ b/cachalot/tests/settings.py
@@ -1,17 +1,19 @@
 from time import sleep
 from unittest import skipIf
+from unittest.mock import MagicMock, patch
 
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.core.cache import DEFAULT_CACHE_ALIAS
-from django.core.checks import run_checks, Tags, Warning, Error
+from django.core.checks import Error, Tags, Warning, run_checks
 from django.db import connection
 from django.test import TransactionTestCase
 from django.test.utils import override_settings
 
 from ..api import invalidate
-from ..settings import SUPPORTED_ONLY, SUPPORTED_DATABASE_ENGINES
-from .models import Test, TestParent, TestChild, UnmanagedModel
+from ..settings import SUPPORTED_DATABASE_ENGINES, SUPPORTED_ONLY
+from ..utils import _get_tables
+from .models import Test, TestChild, TestParent, UnmanagedModel
 from .test_utils import TestUtilsMixin
 
 
@@ -314,3 +316,29 @@ class SettingsTestCase(TestUtilsMixin, TransactionTestCase):
         with self.settings(CACHALOT_DATABASES='invalid value'):
             errors = run_checks(tags=[Tags.compatibility])
             self.assertListEqual(errors, [error002])
+
+    def call_get_tables(self):
+        qs = Test.objects.all()
+        compiler_mock = MagicMock()
+        compiler_mock.__cachalot_generated_sql = ''
+        tables = _get_tables(qs.db, qs.query, compiler_mock)
+        self.assertTrue(tables)
+        return tables
+
+    @override_settings(CACHALOT_FINAL_SQL_CHECK=True)
+    @patch('cachalot.utils._get_tables_from_sql')
+    def test_cachalot_final_sql_check_when_true(self, _get_tables_from_sql):
+        _get_tables_from_sql.return_value = {'patched'}
+        tables = self.call_get_tables()
+        _get_tables_from_sql.assert_called_once()
+        self.assertIn('patched', tables)
+
+
+    @override_settings(CACHALOT_FINAL_SQL_CHECK=False)
+    @patch('cachalot.utils._get_tables_from_sql')
+    def test_cachalot_final_sql_check_when_false(self, _get_tables_from_sql):
+        _get_tables_from_sql.return_value = {'patched'}
+        tables = self.call_get_tables()
+        _get_tables_from_sql.assert_not_called()
+        self.assertNotIn('patched', tables)
+
diff --git a/cachalot/tests/test_utils.py b/cachalot/tests/test_utils.py
index 4bd5fe4..d8db5f0 100644
--- a/cachalot/tests/test_utils.py
+++ b/cachalot/tests/test_utils.py
@@ -2,8 +2,8 @@ from django import VERSION as DJANGO_VERSION
 from django.core.management.color import no_style
 from django.db import connection, transaction
 
-from .models import PostgresModel
 from ..utils import _get_tables
+from .models import PostgresModel
 
 
 class TestUtilsMixin:
@@ -36,7 +36,7 @@ class TestUtilsMixin:
     def assert_tables(self, queryset, *tables):
         tables = {table if isinstance(table, str)
                   else table._meta.db_table for table in tables}
-        self.assertSetEqual(_get_tables(queryset.db, queryset.query), tables)
+        self.assertSetEqual(_get_tables(queryset.db, queryset.query), tables, str(queryset.query))
 
     def assert_query_cached(self, queryset, result=None, result_type=None,
                             compare_results=True, before=1, after=0):
diff --git a/cachalot/tests/tests_decorators.py b/cachalot/tests/tests_decorators.py
new file mode 100644
index 0000000..1a0f876
--- /dev/null
+++ b/cachalot/tests/tests_decorators.py
@@ -0,0 +1,50 @@
+import logging
+from functools import wraps
+
+from django.core.cache import cache
+from django.test.utils import override_settings
+
+logger = logging.getLogger(__name__)
+
+
+def all_final_sql_checks(func):
+    """
+    Runs test as two sub-tests:
+    one with CACHALOT_FINAL_SQL_CHECK setting True, one with False
+    """
+
+    @wraps(func)
+    def wrapper(self, *args, **kwargs):
+        for final_sql_check in (True, False):
+            with self.subTest(msg=f'CACHALOT_FINAL_SQL_CHECK = {final_sql_check}'):
+                with override_settings(
+                        CACHALOT_FINAL_SQL_CHECK=final_sql_check
+                ):
+                    func(self, *args, **kwargs)
+            cache.clear()
+
+    return wrapper
+
+
+def no_final_sql_check(func):
+    """
+    Runs test with CACHALOT_FINAL_SQL_CHECK = False
+    """
+    @wraps(func)
+    def wrapper(self, *args, **kwargs):
+        with override_settings(CACHALOT_FINAL_SQL_CHECK=False):
+            func(self, *args, **kwargs)
+
+    return wrapper
+
+
+def with_final_sql_check(func):
+    """
+    Runs test with CACHALOT_FINAL_SQL_CHECK = True
+    """
+    @wraps(func)
+    def wrapper(self, *args, **kwargs):
+        with override_settings(CACHALOT_FINAL_SQL_CHECK=True):
+            func(self, *args, **kwargs)
+
+    return wrapper
diff --git a/cachalot/tests/transaction.py b/cachalot/tests/transaction.py
index 00d7d82..f55f41e 100644
--- a/cachalot/tests/transaction.py
+++ b/cachalot/tests/transaction.py
@@ -1,6 +1,8 @@
+from cachalot.transaction import AtomicCache
 from django.contrib.auth.models import User
+from django.core.cache import cache
 from django.db import transaction, connection, IntegrityError
-from django.test import TransactionTestCase, skipUnlessDBFeature
+from django.test import SimpleTestCase, TransactionTestCase, skipUnlessDBFeature
 
 from .models import Test
 from .test_utils import TestUtilsMixin
@@ -167,7 +169,7 @@ class AtomicTestCase(TestUtilsMixin, TransactionTestCase):
         with self.assertNumQueries(1):
             data3 = list(Test.objects.all())
         self.assertListEqual(data3, [t1])
-
+    
     @skipUnlessDBFeature('can_defer_constraint_checks')
     def test_deferred_error(self):
         """
@@ -187,3 +189,13 @@ class AtomicTestCase(TestUtilsMixin, TransactionTestCase):
                         '-- ' + Test._meta.db_table)  # Should invalidate Test.
         with self.assertNumQueries(1):
             list(Test.objects.all())
+
+
+class AtomicCacheTestCase(SimpleTestCase):
+    def setUp(self):
+        self.atomic_cache = AtomicCache(cache, 'db_alias')
+    
+    def test_set(self):
+        self.assertDictEqual(self.atomic_cache, {})
+        self.atomic_cache.set('key', 'value', None)
+        self.assertDictEqual(self.atomic_cache, {'key': 'value'})
diff --git a/cachalot/transaction.py b/cachalot/transaction.py
index b8f7b28..c8d37ce 100644
--- a/cachalot/transaction.py
+++ b/cachalot/transaction.py
@@ -3,7 +3,7 @@ from .settings import cachalot_settings
 
 class AtomicCache(dict):
     def __init__(self, parent_cache, db_alias):
-        super(AtomicCache, self).__init__()
+        super().__init__()
         self.parent_cache = parent_cache
         self.db_alias = db_alias
         self.to_be_invalidated = set()
diff --git a/cachalot/utils.py b/cachalot/utils.py
index da5f2e4..a20d2a6 100644
--- a/cachalot/utils.py
+++ b/cachalot/utils.py
@@ -83,6 +83,10 @@ def get_query_cache_key(compiler):
     check_parameter_types(params)
     cache_key = '%s:%s:%s' % (compiler.using, sql,
                               [str(p) for p in params])
+    # Set attribute on compiler for later access
+    # to the generated SQL. This prevents another as_sql() call!
+    compiler.__cachalot_generated_sql = sql.lower()
+
     return sha1(cache_key.encode('utf-8')).hexdigest()
 
 
@@ -101,9 +105,23 @@ def get_table_cache_key(db_alias, table):
     return sha1(cache_key.encode('utf-8')).hexdigest()
 
 
-def _get_tables_from_sql(connection, lowercased_sql):
-    return {t for t in connection.introspection.django_table_names()
-            + cachalot_settings.CACHALOT_ADDITIONAL_TABLES if t in lowercased_sql}
+def _get_tables_from_sql(connection, lowercased_sql, enable_quote: bool = False):
+    """Returns names of involved tables after analyzing the final SQL query."""
+    return {table for table in (connection.introspection.django_table_names()
+            + cachalot_settings.CACHALOT_ADDITIONAL_TABLES)
+            if _quote_table_name(table, connection, enable_quote) in lowercased_sql}
+
+
+def _quote_table_name(table_name, connection, enable_quote: bool):
+    """
+    Returns quoted table name.
+
+    Put database-specific quotation marks around the table name
+    to preven that tables with substrings of the table are considered.
+    E.g. cachalot_testparent must not return cachalot_test.
+    """
+    return f'{connection.ops.quote_name(table_name)}' \
+        if enable_quote else table_name
 
 
 def _find_rhs_lhs_subquery(side):
@@ -135,10 +153,15 @@ def _find_subqueries_in_where(children):
         elif child_class is NothingNode:
             pass
         else:
-            rhs = _find_rhs_lhs_subquery(child.rhs)
+            try:
+                child_rhs = child.rhs
+                child_lhs = child.lhs
+            except AttributeError:
+                raise UncachableQuery
+            rhs = _find_rhs_lhs_subquery(child_rhs)
             if rhs is not None:
                 yield rhs
-            lhs = _find_rhs_lhs_subquery(child.lhs)
+            lhs = _find_rhs_lhs_subquery(child_lhs)
             if lhs is not None:
                 yield lhs
 
@@ -165,7 +188,7 @@ def filter_cachable(tables):
     return tables
 
 
-def _flatten(expression: "BaseExpression"):
+def _flatten(expression: 'BaseExpression'):
     """
     Recursively yield this expression and all subexpressions, in
     depth-first order.
@@ -182,7 +205,7 @@ def _flatten(expression: "BaseExpression"):
                 yield expr
 
 
-def _get_tables(db_alias, query):
+def _get_tables(db_alias, query, compiler=False):
     if query.select_for_update or (
             not cachalot_settings.CACHALOT_CACHE_RANDOM
             and '?' in query.order_by):
@@ -191,6 +214,7 @@ def _get_tables(db_alias, query):
     try:
         if query.extra_select:
             raise IsRawQuery
+
         # Gets all tables already found by the ORM.
         tables = set(query.table_map)
         tables.add(query.get_meta().db_table)
@@ -201,8 +225,10 @@ def _get_tables(db_alias, query):
                 raise UncachableQuery
             for expression in _flatten(annotation):
                 if isinstance(expression, Subquery):
-                    if hasattr(expression, "queryset"):
+                    # Django 2.2 only: no query, only queryset
+                    if not hasattr(expression, 'query'):
                         tables.update(_get_tables(db_alias, expression.queryset.query))
+                    # Django 3+
                     else:
                         tables.update(_get_tables(db_alias, expression.query))
                 elif isinstance(expression, RawSQL):
@@ -225,6 +251,18 @@ def _get_tables(db_alias, query):
     except IsRawQuery:
         sql = query.get_compiler(db_alias).as_sql()[0].lower()
         tables = _get_tables_from_sql(connections[db_alias], sql)
+    else:
+        # Additional check of the final SQL.
+        # Potentially overlooked tables are added here. Tables may be overlooked by the regular checks
+        # as not all expressions are handled yet. This final check acts as safety net.
+        if cachalot_settings.CACHALOT_FINAL_SQL_CHECK:
+            if compiler:
+                # Access generated SQL stored when caching the query!
+                sql = compiler.__cachalot_generated_sql
+            else:
+                sql = query.get_compiler(db_alias).as_sql()[0].lower()
+            final_check_tables = _get_tables_from_sql(connections[db_alias], sql, enable_quote=True)
+            tables.update(final_check_tables)
 
     if not are_all_cachable(tables):
         raise UncachableQuery
@@ -235,7 +273,7 @@ def _get_table_cache_keys(compiler):
     db_alias = compiler.using
     get_table_cache_key = cachalot_settings.CACHALOT_TABLE_KEYGEN
     return [get_table_cache_key(db_alias, t)
-            for t in _get_tables(db_alias, compiler.query)]
+            for t in _get_tables(db_alias, compiler.query, compiler)]
 
 
 def _invalidate_tables(cache, db_alias, tables):
diff --git a/debian/changelog b/debian/changelog
index 5da91ec..8b8d170 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,11 +1,12 @@
-django-cachalot (2.4.3-2) UNRELEASED; urgency=medium
+django-cachalot (2.5.1-1) UNRELEASED; urgency=medium
 
   * Use secure URI in debian/watch.
   * Set debhelper-compat version in Build-Depends.
   * Set upstream metadata fields: Bug-Database, Bug-Submit, Repository,
     Repository-Browse.
+  * New upstream release.
 
- -- Debian Janitor <janitor@jelmer.uk>  Fri, 25 Mar 2022 08:03:38 -0000
+ -- Debian Janitor <janitor@jelmer.uk>  Sun, 24 Apr 2022 02:32:48 -0000
 
 django-cachalot (2.4.3-1) unstable; urgency=low
 
diff --git a/django_cachalot.egg-info/PKG-INFO b/django_cachalot.egg-info/PKG-INFO
index 7b4bca9..7b551b3 100644
--- a/django_cachalot.egg-info/PKG-INFO
+++ b/django_cachalot.egg-info/PKG-INFO
@@ -1,155 +1,11 @@
-Metadata-Version: 1.1
+Metadata-Version: 2.1
 Name: django-cachalot
-Version: 2.4.3
+Version: 2.5.1
 Summary: Caches your Django ORM queries and automatically invalidates them.
 Home-page: https://github.com/noripyt/django-cachalot
 Author: Bertrand Bordage, Andrew Chen Wang
 Author-email: acwangpython@gmail.com
 License: BSD
-Description: Django Cachalot
-        ===============
-        
-        Caches your Django ORM queries and automatically invalidates them.
-        
-        Documentation: http://django-cachalot.readthedocs.io
-        
-        ----
-        
-        .. image:: http://img.shields.io/pypi/v/django-cachalot.svg?style=flat-square&maxAge=3600
-           :target: https://pypi.python.org/pypi/django-cachalot
-        
-        .. image:: https://img.shields.io/pypi/pyversions/django-cachalot
-            :target: https://django-cachalot.readthedocs.io/en/latest/
-        
-        .. image:: https://github.com/noripyt/django-cachalot/actions/workflows/ci.yml/badge.svg
-           :target: https://github.com/noripyt/django-cachalot/actions/workflows/ci.yml
-        
-        .. image:: http://img.shields.io/coveralls/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
-           :target: https://coveralls.io/r/noripyt/django-cachalot?branch=master
-        
-        .. image:: http://img.shields.io/scrutinizer/g/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
-           :target: https://scrutinizer-ci.com/g/noripyt/django-cachalot/
-        
-        .. image:: https://img.shields.io/discord/773656139207802881
-            :target: https://discord.gg/WFGFBk8rSU
-        
-        ----
-        
-        Table of Contents:
-        
-        - Quickstart
-        - Usage
-        - Hacking
-        - Benchmark
-        - Third-Party Cache Comparison
-        - Discussion
-        
-        Quickstart
-        ----------
-        
-        Cachalot officially supports Python 3.6-3.9 and Django 2.2 and 3.1-3.2 with the databases PostgreSQL, SQLite, and MySQL.
-        
-        Note: an upper limit on Django version is set for your safety. Please do not ignore it.
-        
-        Usage
-        -----
-        
-        #. ``pip install django-cachalot``
-        #. Add ``'cachalot',`` to your ``INSTALLED_APPS``
-        #. If you use multiple servers with a common cache server,
-           `double check their clock synchronisation <https://django-cachalot.readthedocs.io/en/latest/limits.html#multiple-servers>`_
-        #. If you modify data outside Django
-           – typically after restoring a SQL database –,
-           use the `manage.py command <https://django-cachalot.readthedocs.io/en/latest/quickstart.html#command>`_
-        #. Be aware of `the few other limits <https://django-cachalot.readthedocs.io/en/latest/limits.html#limits>`_
-        #. If you use
-           `django-debug-toolbar <https://github.com/jazzband/django-debug-toolbar>`_,
-           you can add ``'cachalot.panels.CachalotPanel',``
-           to your ``DEBUG_TOOLBAR_PANELS``
-        #. Enjoy!
-        
-        Hacking
-        -------
-        
-        To start developing, install the requirements
-        and run the tests via tox.
-        
-        Make sure you have the following services:
-        
-        * Memcached
-        * Redis
-        * PostgreSQL
-        * MySQL
-        
-        For setup:
-        
-        #. Install: ``pip install -r requirements/hacking.txt``
-        #. For PostgreSQL: ``CREATE ROLE cachalot LOGIN SUPERUSER;``
-        #. Run: ``tox --current-env`` to run the test suite on your current Python version.
-        #. You can also run specific databases and Django versions: ``tox -e py38-django3.1-postgresql-redis``
-        
-        Benchmark
-        ---------
-        
-        Currently, benchmarks are supported on Linux and Mac/Darwin.
-        You will need a database called "cachalot" on MySQL and PostgreSQL.
-        Additionally, on PostgreSQL, you will need to create a role
-        called "cachalot." You can also run the benchmark, and it'll raise
-        errors with specific instructions for how to fix it.
-        
-        #. Install: ``pip install -r requirements/benchmark.txt``
-        #. Run: ``python benchmark.py``
-        
-        The output will be in benchmark/TODAY'S_DATE/
-        
-        TODO Create Docker-compose file to allow for easier running of data.
-        
-        Third-Party Cache Comparison
-        ----------------------------
-        
-        There are three main third party caches: cachalot, cache-machine, and cache-ops. Which do you use? We suggest a mix:
-        
-        TL;DR Use cachalot for cold or modified <50 times per minutes (Most people should stick with only cachalot since you
-        most likely won't need to scale to the point of needing cache-machine added to the bowl). If you're an enterprise that
-        already has huge statistics, then mixing cold caches for cachalot and your hot caches with cache-machine is the best
-        mix. However, when performing joins with ``select_related`` and ``prefetch_related``, you can
-        get a nearly 100x speed up for your initial deployment.
-        
-        Recall, cachalot caches THE ENTIRE TABLE. That's where its inefficiency stems from: if you keep updating the records,
-        then the cachalot constantly invalidates the table and re-caches. Luckily caching is very efficient, it's just the cache
-        invalidation part that kills all our systems. Look at Note 1 below to see how Reddit deals with it.
-        
-        Cachalot is more-or-less intended for cold caches or "just-right" conditions. If you find a partition library for
-        Django (also authored but work-in-progress by `Andrew Chen Wang`_), then the caching will work better since sharding
-        the cold/accessed-the-least records aren't invalidated as much.
-        
-        Cachalot is good when there are <50 modifications per minute on a hot cached table. This is mostly due to cache invalidation. It's the same with any cache,
-        which is why we suggest you use cache-machine for hot caches. Cache-machine caches individual objects, taking up more in the memory store but
-        invalidates those individual objects instead of the entire table like cachalot.
-        
-        Yes, the bane of our entire existence lies in cache invalidation and naming variables. Why does cachalot suck when
-        stuck with a huge table that's modified rapidly? Since you've mixed your cold (90% of) with your hot (10% of) records,
-        you're caching and invalidating an entire table. It's like trying to boil 1 ton of noodles inside ONE pot instead of
-        100 pots boiling 1 ton of noodles. Which is more efficient? The splitting up of them.
-        
-        Note 1: My personal experience with caches stems from Reddit's: https://redditblog.com/2017/01/17/caching-at-reddit/
-        
-        Note 2: Technical comparison: https://django-cachalot.readthedocs.io/en/latest/introduction.html#comparison-with-similar-tools
-        
-        Discussion
-        ----------
-        
-        Help? Technical chat? `It's here on Discord <https://discord.gg/WFGFBk8rSU>`_.
-        
-        Legacy chats:
-        
-        - https://gitter.im/django-cachalot/Lobby
-        - https://join.slack.com/t/cachalotdjango/shared_invite/zt-dd0tj27b-cIH6VlaSOjAWnTG~II5~qw
-        
-        .. _Andrew Chen Wang: https://github.com/Andrew-Chen-Wang
-        
-        .. image:: https://raw.github.com/noripyt/django-cachalot/master/django-cachalot.jpg
-        
 Platform: UNKNOWN
 Classifier: Development Status :: 5 - Production/Stable
 Classifier: Framework :: Django
@@ -157,11 +13,158 @@ Classifier: Intended Audience :: Developers
 Classifier: License :: OSI Approved :: BSD License
 Classifier: Operating System :: OS Independent
 Classifier: Framework :: Django :: 2.2
-Classifier: Framework :: Django :: 3.1
 Classifier: Framework :: Django :: 3.2
+Classifier: Framework :: Django :: 4.0
 Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.6
 Classifier: Programming Language :: Python :: 3.7
 Classifier: Programming Language :: Python :: 3.8
 Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
 Classifier: Topic :: Internet :: WWW/HTTP
+License-File: LICENSE
+
+Django Cachalot
+===============
+
+Caches your Django ORM queries and automatically invalidates them.
+
+Documentation: http://django-cachalot.readthedocs.io
+
+----
+
+.. image:: http://img.shields.io/pypi/v/django-cachalot.svg?style=flat-square&maxAge=3600
+   :target: https://pypi.python.org/pypi/django-cachalot
+
+.. image:: https://img.shields.io/pypi/pyversions/django-cachalot
+    :target: https://django-cachalot.readthedocs.io/en/latest/
+
+.. image:: https://github.com/noripyt/django-cachalot/actions/workflows/ci.yml/badge.svg
+   :target: https://github.com/noripyt/django-cachalot/actions/workflows/ci.yml
+
+.. image:: http://img.shields.io/coveralls/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
+   :target: https://coveralls.io/r/noripyt/django-cachalot?branch=master
+
+.. image:: http://img.shields.io/scrutinizer/g/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
+   :target: https://scrutinizer-ci.com/g/noripyt/django-cachalot/
+
+.. image:: https://img.shields.io/discord/773656139207802881
+    :target: https://discord.gg/WFGFBk8rSU
+
+----
+
+Table of Contents:
+
+- Quickstart
+- Usage
+- Hacking
+- Benchmark
+- Third-Party Cache Comparison
+- Discussion
+
+Quickstart
+----------
+
+Cachalot officially supports Python 3.7-3.10 and Django 2.2, 3.2, and 4.0 with the databases PostgreSQL, SQLite, and MySQL.
+
+Note: an upper limit on Django version is set for your safety. Please do not ignore it.
+
+Usage
+-----
+
+#. ``pip install django-cachalot``
+#. Add ``'cachalot',`` to your ``INSTALLED_APPS``
+#. If you use multiple servers with a common cache server,
+   `double check their clock synchronisation <https://django-cachalot.readthedocs.io/en/latest/limits.html#multiple-servers>`_
+#. If you modify data outside Django
+   – typically after restoring a SQL database –,
+   use the `manage.py command <https://django-cachalot.readthedocs.io/en/latest/quickstart.html#command>`_
+#. Be aware of `the few other limits <https://django-cachalot.readthedocs.io/en/latest/limits.html#limits>`_
+#. If you use
+   `django-debug-toolbar <https://github.com/jazzband/django-debug-toolbar>`_,
+   you can add ``'cachalot.panels.CachalotPanel',``
+   to your ``DEBUG_TOOLBAR_PANELS``
+#. Enjoy!
+
+Hacking
+-------
+
+To start developing, install the requirements
+and run the tests via tox.
+
+Make sure you have the following services:
+
+* Memcached
+* Redis
+* PostgreSQL
+* MySQL
+
+For setup:
+
+#. Install: ``pip install -r requirements/hacking.txt``
+#. For PostgreSQL: ``CREATE ROLE cachalot LOGIN SUPERUSER;``
+#. Run: ``tox --current-env`` to run the test suite on your current Python version.
+#. You can also run specific databases and Django versions: ``tox -e py38-django3.1-postgresql-redis``
+
+Benchmark
+---------
+
+Currently, benchmarks are supported on Linux and Mac/Darwin.
+You will need a database called "cachalot" on MySQL and PostgreSQL.
+Additionally, on PostgreSQL, you will need to create a role
+called "cachalot." You can also run the benchmark, and it'll raise
+errors with specific instructions for how to fix it.
+
+#. Install: ``pip install -r requirements/benchmark.txt``
+#. Run: ``python benchmark.py``
+
+The output will be in benchmark/TODAY'S_DATE/
+
+TODO Create Docker-compose file to allow for easier running of data.
+
+Third-Party Cache Comparison
+----------------------------
+
+There are three main third party caches: cachalot, cache-machine, and cache-ops. Which do you use? We suggest a mix:
+
+TL;DR Use cachalot for cold or modified <50 times per minutes (Most people should stick with only cachalot since you
+most likely won't need to scale to the point of needing cache-machine added to the bowl). If you're an enterprise that
+already has huge statistics, then mixing cold caches for cachalot and your hot caches with cache-machine is the best
+mix. However, when performing joins with ``select_related`` and ``prefetch_related``, you can
+get a nearly 100x speed up for your initial deployment.
+
+Recall, cachalot caches THE ENTIRE TABLE. That's where its inefficiency stems from: if you keep updating the records,
+then the cachalot constantly invalidates the table and re-caches. Luckily caching is very efficient, it's just the cache
+invalidation part that kills all our systems. Look at Note 1 below to see how Reddit deals with it.
+
+Cachalot is more-or-less intended for cold caches or "just-right" conditions. If you find a partition library for
+Django (also authored but work-in-progress by `Andrew Chen Wang`_), then the caching will work better since sharding
+the cold/accessed-the-least records aren't invalidated as much.
+
+Cachalot is good when there are <50 modifications per minute on a hot cached table. This is mostly due to cache invalidation. It's the same with any cache,
+which is why we suggest you use cache-machine for hot caches. Cache-machine caches individual objects, taking up more in the memory store but
+invalidates those individual objects instead of the entire table like cachalot.
+
+Yes, the bane of our entire existence lies in cache invalidation and naming variables. Why does cachalot suck when
+stuck with a huge table that's modified rapidly? Since you've mixed your cold (90% of) with your hot (10% of) records,
+you're caching and invalidating an entire table. It's like trying to boil 1 ton of noodles inside ONE pot instead of
+100 pots boiling 1 ton of noodles. Which is more efficient? The splitting up of them.
+
+Note 1: My personal experience with caches stems from Reddit's: https://redditblog.com/2017/01/17/caching-at-reddit/
+
+Note 2: Technical comparison: https://django-cachalot.readthedocs.io/en/latest/introduction.html#comparison-with-similar-tools
+
+Discussion
+----------
+
+Help? Technical chat? `It's here on Discord <https://discord.gg/WFGFBk8rSU>`_.
+
+Legacy chats:
+
+- https://gitter.im/django-cachalot/Lobby
+- https://join.slack.com/t/cachalotdjango/shared_invite/zt-dd0tj27b-cIH6VlaSOjAWnTG~II5~qw
+
+.. _Andrew Chen Wang: https://github.com/Andrew-Chen-Wang
+
+.. image:: https://raw.github.com/noripyt/django-cachalot/master/django-cachalot.jpg
+
+
diff --git a/django_cachalot.egg-info/SOURCES.txt b/django_cachalot.egg-info/SOURCES.txt
index 8cf5aca..de821d9 100644
--- a/django_cachalot.egg-info/SOURCES.txt
+++ b/django_cachalot.egg-info/SOURCES.txt
@@ -3,7 +3,11 @@ LICENSE
 MANIFEST.in
 README.rst
 requirements.txt
+runtests.py
+runtests_urls.py
+settings.py
 setup.py
+tox.ini
 cachalot/__init__.py
 cachalot/api.py
 cachalot/apps.py
@@ -34,6 +38,7 @@ cachalot/tests/read.py
 cachalot/tests/settings.py
 cachalot/tests/signals.py
 cachalot/tests/test_utils.py
+cachalot/tests/tests_decorators.py
 cachalot/tests/thread_safety.py
 cachalot/tests/transaction.py
 cachalot/tests/write.py
@@ -44,4 +49,18 @@ django_cachalot.egg-info/SOURCES.txt
 django_cachalot.egg-info/dependency_links.txt
 django_cachalot.egg-info/not-zip-safe
 django_cachalot.egg-info/requires.txt
-django_cachalot.egg-info/top_level.txt
\ No newline at end of file
+django_cachalot.egg-info/top_level.txt
+docs/Makefile
+docs/api.rst
+docs/benchmark.rst
+docs/changelog.rst
+docs/conf.py
+docs/how.rst
+docs/index.rst
+docs/introduction.rst
+docs/legacy.rst
+docs/limits.rst
+docs/quickstart.rst
+docs/reporting.rst
+docs/requirements.txt
+docs/todo.rst
\ No newline at end of file
diff --git a/django_cachalot.egg-info/requires.txt b/django_cachalot.egg-info/requires.txt
index a3687b5..8480fb7 100644
--- a/django_cachalot.egg-info/requires.txt
+++ b/django_cachalot.egg-info/requires.txt
@@ -1 +1 @@
-Django<3.3,>=2.2
+Django<4.1,>=2.2
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..264c86e
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,177 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+PAPER         =
+BUILDDIR      = _build
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
+$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
+endif
+
+# Internal variables.
+PAPEROPT_a4     = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
+
+help:
+	@echo "Please use \`make <target>' where <target> is one of"
+	@echo "  html       to make standalone HTML files"
+	@echo "  dirhtml    to make HTML files named index.html in directories"
+	@echo "  singlehtml to make a single large HTML file"
+	@echo "  pickle     to make pickle files"
+	@echo "  json       to make JSON files"
+	@echo "  htmlhelp   to make HTML files and a HTML help project"
+	@echo "  qthelp     to make HTML files and a qthelp project"
+	@echo "  devhelp    to make HTML files and a Devhelp project"
+	@echo "  epub       to make an epub"
+	@echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+	@echo "  latexpdf   to make LaTeX files and run them through pdflatex"
+	@echo "  latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+	@echo "  text       to make text files"
+	@echo "  man        to make manual pages"
+	@echo "  texinfo    to make Texinfo files"
+	@echo "  info       to make Texinfo files and run them through makeinfo"
+	@echo "  gettext    to make PO message catalogs"
+	@echo "  changes    to make an overview of all changed/added/deprecated items"
+	@echo "  xml        to make Docutils-native XML files"
+	@echo "  pseudoxml  to make pseudoxml-XML files for display purposes"
+	@echo "  linkcheck  to check all external links for integrity"
+	@echo "  doctest    to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+	rm -rf $(BUILDDIR)/*
+
+html:
+	$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+	$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+	@echo
+	@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+	$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+	@echo
+	@echo "Build finished; now you can process the pickle files."
+
+json:
+	$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+	@echo
+	@echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+	$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+	@echo
+	@echo "Build finished; now you can run HTML Help Workshop with the" \
+	      ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+	$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+	@echo
+	@echo "Build finished; now you can run "qcollectiongenerator" with the" \
+	      ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+	@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-cachalot.qhcp"
+	@echo "To view the help file:"
+	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-cachalot.qhc"
+
+devhelp:
+	$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+	@echo
+	@echo "Build finished."
+	@echo "To view the help file:"
+	@echo "# mkdir -p $$HOME/.local/share/devhelp/django-cachalot"
+	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-cachalot"
+	@echo "# devhelp"
+
+epub:
+	$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+	@echo
+	@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo
+	@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+	@echo "Run \`make' in that directory to run these through (pdf)latex" \
+	      "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through pdflatex..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+latexpdfja:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through platex and dvipdfmx..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+	$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+	@echo
+	@echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+	@echo
+	@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo
+	@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+	@echo "Run \`make' in that directory to run these through makeinfo" \
+	      "(use \`make info' here to do that automatically)."
+
+info:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo "Running Texinfo files through makeinfo..."
+	make -C $(BUILDDIR)/texinfo info
+	@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+	$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+	@echo
+	@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+	$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+	@echo
+	@echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+	$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+	@echo
+	@echo "Link check complete; look for any errors in the above output " \
+	      "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+	$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+	@echo "Testing of doctests in the sources finished, look at the " \
+	      "results in $(BUILDDIR)/doctest/output.txt."
+
+xml:
+	$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+	@echo
+	@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+pseudoxml:
+	$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+	@echo
+	@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
diff --git a/docs/api.rst b/docs/api.rst
new file mode 100644
index 0000000..4b77294
--- /dev/null
+++ b/docs/api.rst
@@ -0,0 +1,11 @@
+.. _API:
+
+API
+---
+
+Use these tools to interact with django-cachalot, especially if you face
+:ref:`raw queries limits <Raw SQL queries>` or if you need to create a cache key from the
+last table invalidation timestamp.
+
+.. automodule:: cachalot.api
+   :members:
diff --git a/docs/benchmark.rst b/docs/benchmark.rst
new file mode 100644
index 0000000..cd68494
--- /dev/null
+++ b/docs/benchmark.rst
@@ -0,0 +1,116 @@
+.. _Benchmark:
+
+Benchmark
+---------
+
+.. contents::
+
+Introduction
+............
+
+This benchmark does not intend to be exhaustive nor fair to SQL.
+It shows how django-cachalot behaves on an unoptimised application.
+On an application using perfectly optimised SQL queries only,
+django-cachalot may not be useful.
+Unfortunately, most Django apps (including Django itself)
+use unoptimised queries. Of course, they often lack useful indexes
+(even though it only requires 20 characters per index…).
+But what you may not know is that
+**the ORM currently generates totally unoptimised queries** [#]_.
+
+You can run the benchmarks yourself (officially supported on Linux
+and Mac). You will need a database called "cachalot" on MySQL and PostgreSQL.
+Additionally, on PostgreSQL, you will need to create a role
+called "cachalot." Running the benchmarks can raise
+errors with specific instructions for how to fix it.
+
+#. Install: ``pip install -r requirements/benchmark.txt``
+#. Run: ``python benchmark.py``
+
+The output will be in benchmark/TODAY'S_DATE/
+
+Conditions
+..........
+
+.. include:: ../benchmark/docs/2018-08-09/conditions.rst
+
+Note that
+`MySQL’s query cache <http://dev.mysql.com/doc/refman/5.7/en/query-cache.html>`_
+is active during the benchmark.
+
+Database results
+................
+
+.. include:: ../benchmark/docs/2018-08-09/db_results.rst
+
+.. image:: ../benchmark/docs/2018-08-09/db.svg
+
+
+Cache results
+.............
+
+.. include:: ../benchmark/docs/2018-08-09/cache_results.rst
+
+.. image:: ../benchmark/docs/2018-08-09/cache.svg
+
+
+Database detailed results
+.........................
+
+MySQL
+~~~~~
+
+.. image:: ../benchmark/docs/2018-08-09/db_mysql.svg
+
+PostgreSQL
+~~~~~~~~~~
+
+.. image:: ../benchmark/docs/2018-08-09/db_postgresql.svg
+
+SQLite
+~~~~~~
+
+.. image:: ../benchmark/docs/2018-08-09/db_sqlite.svg
+
+
+Cache detailed results
+......................
+
+File-based
+~~~~~~~~~~
+
+.. image:: ../benchmark/docs/2018-08-09/cache_filebased.svg
+
+Locmem
+~~~~~~
+
+.. image:: ../benchmark/docs/2018-08-09/cache_locmem.svg
+
+Memcached (python-memcached)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. image:: ../benchmark/docs/2018-08-09/cache_memcached.svg
+
+Memcached (pylibmc)
+~~~~~~~~~~~~~~~~~~~
+
+.. image:: ../benchmark/docs/2018-08-09/cache_pylibmc.svg
+
+Redis
+~~~~~
+
+.. image:: ../benchmark/docs/2018-08-09/cache_redis.svg
+
+
+
+.. [#] The ORM fetches way too much data if you don’t restrict it using
+       ``.only`` and ``.defer``. You can divide the execution time
+       of most queries by 2-3 by specifying what you want to fetch.
+       But specifying which data we want for each query is very long
+       and unmaintainable. An automation using field usage statistics
+       is possible and would drastically improve performance.
+       Other performance issues occur with slicing.
+       You can often optimise a sliced query using a subquery, like
+       ``YourModel.objects.filter(pk__in=YourModel.objects.filter(…)[10000:10050]).select_related(…)``
+       instead of ``YourModel.objects.filter(…).select_related(…)[10000:10050]``.
+       I’ll maybe work on these issues one day.
diff --git a/docs/changelog.rst b/docs/changelog.rst
new file mode 100644
index 0000000..565b052
--- /dev/null
+++ b/docs/changelog.rst
@@ -0,0 +1 @@
+.. include:: ../CHANGELOG.rst
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..632063e
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+#
+# django-cachalot documentation build configuration file, created by
+# sphinx-quickstart on Tue Oct 28 22:46:50 2014.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import sys
+import os
+
+sys.path.insert(0, os.path.abspath('..'))
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings')
+import cachalot
+
+# This sets up Django, necessary for autodoc
+import runtests
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#sys.path.insert(0, os.path.abspath('.'))
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+    'sphinx.ext.autodoc',
+    'sphinx.ext.intersphinx',
+    'sphinx.ext.todo',
+    'sphinx.ext.coverage',
+    'sphinx.ext.ifconfig',
+    'sphinx.ext.viewcode',
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+#source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = 'django-cachalot'
+copyright = '2014-2016, Bertrand Bordage'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = '%s.%s' % cachalot.VERSION[:2]
+# The full version, including alpha/beta/rc tags.
+release = cachalot.__version__
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+#today = ''
+# Else, today_fmt is used as the format for a strftime call.
+#today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ['_build']
+
+# The reST default role (used for this markup: `text`) to use for all
+# documents.
+#default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+#add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+#add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+#show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+#modindex_common_prefix = []
+
+# If true, keep warnings as "system message" paragraphs in the built documents.
+#keep_warnings = False
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+html_theme = 'sphinx_rtd_theme'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+#html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+#html_theme_path = []
+
+# The name for this set of Sphinx documents.  If None, it defaults to
+# "<project> v<release> documentation".
+#html_title = None
+
+# A shorter title for the navigation bar.  Default is the same as html_title.
+#html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+#html_logo = None
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+#html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# Add any extra paths that contain custom files (such as robots.txt or
+# .htaccess) here, relative to this directory. These files are copied
+# directly to the root of the documentation.
+#html_extra_path = []
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
+# using the given strftime format.
+#html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+#html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+#html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+#html_additional_pages = {}
+
+# If false, no module index is generated.
+#html_domain_indices = True
+
+# If false, no index is generated.
+#html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+#html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+#html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+#html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+#html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it.  The value of this option must be the
+# base URL from which the finished HTML is served.
+#html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+#html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'django-cachalotdoc'
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+# The paper size ('letterpaper' or 'a4paper').
+#'papersize': 'letterpaper',
+
+# The font size ('10pt', '11pt' or '12pt').
+#'pointsize': '10pt',
+
+# Additional stuff for the LaTeX preamble.
+#'preamble': '',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+#  author, documentclass [howto, manual, or own class]).
+latex_documents = [
+  ('index', 'django-cachalot.tex', u'django-cachalot Documentation',
+   u'Bertrand Bordage', 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+#latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+#latex_use_parts = False
+
+# If true, show page references after internal links.
+#latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+#latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+#latex_appendices = []
+
+# If false, no module index is generated.
+#latex_domain_indices = True
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    ('index', 'django-cachalot', u'django-cachalot Documentation',
+     [u'Bertrand Bordage'], 1)
+]
+
+# If true, show URL addresses after external links.
+#man_show_urls = False
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+  ('index', 'django-cachalot', u'django-cachalot Documentation',
+   u'Bertrand Bordage', 'django-cachalot', 'One line description of project.',
+   'Miscellaneous'),
+]
+
+# Documents to append as an appendix to all manuals.
+#texinfo_appendices = []
+
+# If false, no module index is generated.
+#texinfo_domain_indices = True
+
+# How to display URL addresses: 'footnote', 'no', or 'inline'.
+#texinfo_show_urls = 'footnote'
+
+# If true, do not generate a @detailmenu in the "Top" node's menu.
+#texinfo_no_detailmenu = False
+
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {'http://docs.python.org/': None}
diff --git a/docs/how.rst b/docs/how.rst
new file mode 100644
index 0000000..a1e76ae
--- /dev/null
+++ b/docs/how.rst
@@ -0,0 +1,22 @@
+How django-cachalot works
+-------------------------
+
+.. note:: If you don’t understand, you can pretend it’s magic.
+
+Reverse engineering
+...................
+
+It’s a lot of Django reverse engineering combined with a strong test suite.
+Such a test suite is crucial for a reverse engineering project.
+If some important part of Django changes and breaks the expected behaviour,
+you can be sure that the test suite will fail.
+
+Monkey patching
+...............
+
+Django-cachalot modifies Django in place during execution to add a caching tool
+just before SQL queries are executed.
+When a SQL query reads data, we save the result in cache. If that same query is
+executed later, we fetch that result from cache.
+When we detect ``INSERT``, ``UPDATE`` or ``DELETE``, we know which tables are
+modified. All the previous cached queries can therefore be safely invalidated.
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..fedf53f
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,90 @@
+***************
+django-cachalot
+***************
+
+Caches your Django ORM queries and automatically invalidates them.
+
+.. image:: https://raw.github.com/noripyt/django-cachalot/master/django-cachalot.jpg
+
+----
+
+.. image:: http://img.shields.io/pypi/v/django-cachalot.svg?style=flat-square&maxAge=3600
+   :target: https://pypi.python.org/pypi/django-cachalot
+
+.. image:: http://img.shields.io/travis/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
+   :target: https://travis-ci.org/noripyt/django-cachalot
+
+.. image:: http://img.shields.io/coveralls/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
+   :target: https://coveralls.io/r/noripyt/django-cachalot?branch=master
+
+.. image:: http://img.shields.io/scrutinizer/g/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
+   :target: https://scrutinizer-ci.com/g/noripyt/django-cachalot/
+
+.. image:: https://img.shields.io/discord/773656139207802881
+    :target: https://discord.gg/WFGFBk8rSU
+
+Usage
+.....
+
+#. ``pip install django-cachalot``
+#. Add ``'cachalot',`` to your ``INSTALLED_APPS``
+#. If you use multiple servers with a common cache server,
+   :ref:`double check their clock synchronisation <https://django-cachalot.readthedocs.io/en/latest/limits.html#multiple-servers>`_
+#. If you modify data outside Django
+   – typically after restoring a SQL database –,
+   use the :ref:`manage.py command <https://django-cachalot.readthedocs.io/en/latest/quickstart.html#command>`_
+#. Be aware of :ref:`the few other limits <https://django-cachalot.readthedocs.io/en/latest/limits.html#limits>`_
+#. If you use
+   `django-debug-toolbar <https://github.com/jazzband/django-debug-toolbar>`_,
+   you can add ``'cachalot.panels.CachalotPanel',``
+   to your ``DEBUG_TOOLBAR_PANELS``
+#. Enjoy!
+
+Note: In settings, you can use `CACHALOT_UNCACHABLE_TABLES <https://django-cachalot.readthedocs.io/en/latest/quickstart.html#cachalot-only-cachable-tables>`_ as a frozenset of table names (e.g. "public_test" if public was the app name and test is a model name).
+
+Why use cachalot? `Check out our comparison <https://django-cachalot.readthedocs.io/en/latest/introduction.html#comparison-with-similar-tools>`_
+
+Below the tree is an in-depth opinion from the new maintainer:
+
+.. toctree::
+   :maxdepth: 2
+
+   introduction
+   quickstart
+   limits
+   api
+   benchmark
+   todo
+   reporting
+   how
+   legacy
+   changelog
+
+In-depth opinion (from new maintainer):
+
+There are three main third party caches: cachalot, cache-machine, and cache-ops. Which do you use? We suggest a mix:
+
+TL;DR Use cachalot for cold or modified <50 times per minutes (Most people should stick with only cachalot since you
+most likely won't need to scale to the point of needing cache-machine added to the bowl). If you're an enterprise that
+already has huge statistics, then mixing cold caches for cachalot and your hot caches with cache-machine is the best
+mix. However, when performing joins with select_related and prefetch_related, you can
+get a nearly 100x speed up for your initial deployment.
+
+Recall, cachalot caches THE ENTIRE TABLE. That's where its inefficiency stems from: if you keep updating the records,
+then the cachalot constantly invalidates the table and re-caches. Luckily caching is very efficient, it's just the cache
+invalidation part that kills all our systems. Look at Note 1 below to see how Reddit deals with it.
+
+Cachalot is more-or-less intended for cold caches or "just-right" conditions. If you find a partition library for
+Django (also authored but work-in-progress by `Andrew Chen Wang <https://github.com/Andrew-Chen-Wang>`_),
+then the caching will work better since sharding the cold/accessed-the-least records aren't invalidated as much.
+
+Cachalot is good when there are <50 modifications per minute on a hot cached table. This is mostly due to cache invalidation. It's the same with any cache,
+which is why we suggest you use cache-machine for hot caches. Cache-machine caches individual objects, taking up more in the memory store but
+invalidates those individual objects instead of the entire table like cachalot.
+
+Yes, the bane of our entire existence lies in cache invalidation and naming variables. Why does cachalot suck when stuck
+with a huge table that's modified rapidly? Since you've mixed your cold (90% of) with your hot (10% of) records, you're
+caching and invalidating an entire table. It's like trying to boil 1 ton of noodles inside ONE pot instead of 100 pots
+boiling 1 ton of noodles. Which is more efficient? The splitting up of them.
+
+Note 1: My personal experience with caches stems from Reddit's: https://redditblog.com/2017/01/17/caching-at-reddit/
diff --git a/docs/introduction.rst b/docs/introduction.rst
new file mode 100644
index 0000000..3d255c5
--- /dev/null
+++ b/docs/introduction.rst
@@ -0,0 +1,146 @@
+.. _Introduction:
+
+Introduction
+------------
+
+Should you use it?
+..................
+
+Django-cachalot is the perfect speedup tool for most Django projects.
+It will speedup a website of 100 000 visits per month without any problem.
+In fact, **the more visitors you have, the faster the website becomes**.
+That’s because every possible SQL query on the project ends up being cached.
+
+Django-cachalot is especially efficient in the Django administration website
+since it’s unfortunately badly optimised (use foreign keys in ``list_editable``
+if you need to be convinced). One of the best suited is ``select_related`` and
+``prefetch_related`` operations.
+
+However, it’s not suited for projects where there is **a high number
+of modifications per minute** on each table, like a social network with
+more than a 50 messages per minute. Django-cachalot may still give a small
+speedup in such cases, but it may also slow things a bit
+(in the worst case scenario, a 20% slowdown,
+according to :ref:`the benchmark <Benchmark>`).
+If you have a website like that, optimising your SQL database and queries
+is the number one thing you have to do.
+
+There is also an obvious case where you don’t need django-cachalot:
+when the project is already fast enough (all pages load in less than 300 ms).
+Like any other dependency, django-cachalot is a potential source of problems
+(even though it’s currently bug free).
+Don’t use dependencies you can avoid, a “future you” may thank you for that.
+
+Features
+........
+
+- **Saves in cache the results of any SQL query** generated by the Django ORM
+  that reads data. These saved results are then returned instead
+  of executing the same SQL query, which is faster.
+- The first time a query is executed is about 10% slower, then the following
+  times are way faster (7× faster being the average).
+- Automatically invalidates saved results,
+  so that **you never get stale results**.
+- **Invalidates per table, not per object**: if you change an object,
+  all the queries done on other objects of the same model are also invalidated.
+  This is unfortunately technically impossible to make a reliable
+  per-object cache.  Don’t be fooled by packages pretending having
+  that per-object feature, they are unreliable and dangerous for your data.
+- **Handles everything in the ORM**. You can use the most advanced features
+  from the ORM without a single issue, django-cachalot is extremely robust.
+- An easy control thanks to :ref:`settings` and :ref:`a simple API <API>`.
+  But that’s only required if you have a complex infrastructure.  Most people
+  will never use settings or the API.
+- A few bonus features like
+  :ref:`a signal triggered at each database change <Signal>`
+  (including bulk changes) and
+  :ref:`a template tag for a better template fragment caching <Template utils>`.
+
+Comparison with similar tools
+.............................
+
+This comparison was done in December 2015.  It compares django-cachalot
+to the other popular automatic ORM caches at the moment:
+`django-cache-machine <https://github.com/django-cache-machine/django-cache-machine>`_
+& `django-cacheops <https://github.com/Suor/django-cacheops>`_.
+
+Features
+~~~~~~~~
+
+===================================================== ========= ============= ==========
+Feature                                               cachalot  cache-machine cacheops
+===================================================== ========= ============= ==========
+Easy to install                                       ✔         ✘             quite
+Cache agnostic                                        ✔         ✔             ✘
+Type of invalidation                                  per table per object    per query
+CPU performance                                       excellent excellent     excellent
+Memory performance                                    excellent good          excellent
+Reliable                                              ✔         ✘             ✘
+Useful for > 50 modifications per minute              ✘         ✔             ✔
+Handles transactions                                  ✔         ✘             ✘
+Handles Django admin save                             ✔         ✘             ✘
+Handles multi-table inheritance                       ✔         ✔             ✘
+Handles ``QuerySet.count``                            ✔         ✘             ✔
+Handles ``QuerySet.aggregate``/``annotate``           ✔         ✔             ✘
+Handles ``QuerySet.update``                           ✔         ✘             ✘
+Handles ``QuerySet.select_related``                   ✔         ✔             ✘
+Handles ``QuerySet.extra``                            ✔         ✘             ✘
+Handles ``QuerySet.values``/``values_list``           ✔         ✘             ✔
+Handles ``QuerySet.dates``/``datetimes``              ✔         ✘             ✔
+Handles subqueries                                    ✔         ✔             ✘
+Handles querysets generating a SQL ``HAVING`` keyword ✔         ✔             ✘
+Handles ``cursor.execute``                            ✔         ✘             ✘
+Handles the Django command ``flush``                  ✔         ✘             ✘
+===================================================== ========= ============= ==========
+
+Explanations
+''''''''''''
+
+“Handles [a feature]” means that the package correctly invalidates SQL queries
+using that feature. So if a package doesn’t handle a feature, you may get
+stale query results when using this feature.
+It does not mean that it caches a query with this feature, although
+django-cachalot caches all queries except random queries
+or those ran through ``cursor.execute``.
+
+This comparison was done by running the test suite of cachalot against
+cache-machine & cacheops. This test suite is indeed relevant for other
+packages (such as cache-machine & cacheops) since most of it is written in
+a cachalot-independent way.
+
+Similarly, the performance comparison was done using our benchmark,
+coupled with a memory measure.
+
+To me, cache-machine & cacheops are not reliable because of these reasons:
+
+- Neither cache-machine or cacheops handle transactions, which is critical.
+  **Transactions are used a lot in Django internals**: at least
+  in any Django admin save, many-to-many relations modification,
+  bulk creation or update, migrations, session save.
+  If an error occurs during one of these operations, good luck finding
+  if stale data is returned. The best you can do in this case is manually
+  clearing the cache.
+- If you use a query that’s not handled, you may get stale data. It ends up
+  ruining your database since it lets you save modifications to stale data,
+  therefore overwriting the latest version that’s in the database.
+  And you always end up using queries that are not handled since there is no
+  list of unhandled queries in the documentation of each module.
+- In the case of cache-machine, another issue is that it relies
+  on “flush lists”, which can’t work reliably when implemented in a cache
+  like this (see `cache-machine#107 <https://github.com/django-cache-machine/django-cache-machine/issues/107>`_).
+
+
+Number of lines of code
+~~~~~~~~~~~~~~~~~~~~~~~
+
+Django-cachalot tries to be as minimalist as possible, while handling most
+use cases. Being minimalist is essential to create maintainable projects,
+and having a large test suite is essential to get an excellent quality.
+The statistics below speak for themselves…
+
+============ ======== ============= ========
+Project part cachalot cache-machine cacheops
+============ ======== ============= ========
+Application  743      843           1662
+Tests        3023     659           1491
+============ ======== ============= ========
diff --git a/docs/legacy.rst b/docs/legacy.rst
new file mode 100644
index 0000000..c9bd4ef
--- /dev/null
+++ b/docs/legacy.rst
@@ -0,0 +1,14 @@
+Legacy
+------
+
+This work is highly inspired of
+`johnny-cache <https://github.com/jmoiron/johnny-cache>`_, another easy-to-use
+ORM caching tool!  It’s working with Django <= 1.5.
+I used it in production during 3 years, it’s an excellent module!
+
+Unfortunately, we failed to make it migrate to Django 1.6 (I was involved).
+It was mostly because of the transaction system that was entirely refactored.
+
+I also noticed a few advanced invalidation issues when using ``QuerySet.extra``
+and some complex cases implying multi-table inheritance
+and related ``ManyToManyField``.
diff --git a/docs/limits.rst b/docs/limits.rst
new file mode 100644
index 0000000..356384e
--- /dev/null
+++ b/docs/limits.rst
@@ -0,0 +1,201 @@
+.. _Limits:
+
+Limits
+------
+
+High rate of database modifications
+...................................
+
+Do not use django-cachalot if your project has more than 50 database
+modifications per minute on most of its tables. There will be no problem,
+but django-cachalot will become inefficient and will end up slowing
+your project instead of speeding it.
+Read :ref:`the introduction <Introduction>` for more details.
+
+Redis
+.....
+
+By default, Redis will not evict persistent cache keys (those with a ``None``
+timeout) when the maximum memory has been reached. The cache keys created
+by django-cachalot are persistent by default, so if Redis runs out of memory,
+django-cachalot and all other ``cache.set`` will raise
+``ResponseError: OOM command not allowed when used memory > 'maxmemory'.``
+because Redis is not allowed to delete persistent keys.
+
+To avoid this, 2 solutions:
+
+- If you only store disposable data in Redis, you can change
+  ``maxmemory-policy`` to ``allkeys-lru`` in your Redis configuration.
+  Be aware that this setting is global; all your Redis databases will use it.
+  **If you don’t know what you’re doing, use the next solution or use
+  another cache backend.**
+- Increase ``maxmemory`` in your Redis configuration.
+  You can start by setting it to a high value (for example half of your RAM)
+  then decrease it by looking at the Redis database maximum size using
+  ``redis-cli info memory``.
+
+For more information, read
+`Using Redis as a LRU cache <http://redis.io/topics/lru-cache>`_.
+
+Memcached
+.........
+
+By default, memcached is configured for small servers.
+The maximum amount of memory used by memcached is 64 MB,
+and the maximum memory per cache key is 1 MB. This latter limit can lead to
+weird unhandled exceptions such as
+``Error: error 37 from memcached_set: SUCCESS``
+if you execute queries returning more than 1 MB of data.
+
+To increase these limits, set the ``-I`` and ``-m`` arguments when starting
+memcached. If you use Ubuntu and installed the package, you can modify
+`/etc/memcached.conf`, add ``-I 10m`` on a newline to set the limit
+per cache key to 10 MB, and if you want increase the already existing ``-m 64``
+to something like ``-m 1000`` to set the maximum cache size to 1 GB.
+
+.. _Locmem:
+
+Locmem
+......
+
+Locmem is a just a ``dict`` stored in a single Python process.
+It’s not shared between processes, so don’t use locmem with django-cachalot
+in a multi-processes project, if you use RQ or Celery for instance.
+
+Filebased
+.........
+
+Filebased, a simple persistent cache implemented in Django, has a small bug
+(`#25501 <https://code.djangoproject.com/ticket/25501>`_):
+it cannot cache some objects, like psycopg2 ranges.
+If you use range fields from `django.contrib.postgres` and your Django
+version is affected by this bug, you need to add the tables using range fields
+to :ref:`CACHALOT_UNCACHABLE_TABLES`.
+
+.. _MySQL:
+
+MySQL
+.....
+
+This database software already provides by default something like
+django-cachalot:
+`MySQL query cache <http://dev.mysql.com/doc/refman/5.7/en/query-cache.html>`_.
+Unfortunately, this built-in query cache has no significant effect
+since at least MySQL 5.7. However, in MySQL 5.5 it was working so well that
+django-cachalot was not improving performance.
+So depending on the MySQL version, django-cachalot may be useless.
+See the current :ref:`django-cachalot benchmark <Benchmark>` and compare it with
+`an older run of the same benchmark <http://django-cachalot.readthedocs.io/en/1.2.0/benchmark.html>`_
+to see the clear difference: MySQL became 4 × slower since then!
+
+.. _Raw SQL queries:
+
+Raw SQL queries
+...............
+
+.. note::
+   Don’t worry if you don’t understand what follow. That probably means you
+   don’t use raw queries, and therefore are not directly concerned by
+   those potential issues.
+
+By default, django-cachalot tries to invalidate its cache after a raw query.
+It detects if the raw query contains ``UPDATE``, ``INSERT``, ``DELETE``,
+``ALTER``, ``CREATE`` or ``DROP`` and then invalidates the tables contained
+in that query by comparing with models registered by Django.
+
+This is quite robust, so if a query is not invalidated automatically
+by this system, please :ref:`send a bug report <Reporting>`.
+In the meantime, you can use :ref:`the API <API>` to manually invalidate
+the tables where data has changed.
+
+However, this simple system can be too efficient in some very rare cases
+and lead to unwanted extra invalidations.
+
+.. _Multiple servers:
+
+Multiple servers clock synchronisation
+......................................
+
+Django-cachalot relies on the computer clock to handle invalidation.
+If you deploy the same Django project on multiple machines,
+but with a centralised cache server, all the machines serving Django need
+to have their clocks as synchronised as possible.
+Otherwise, invalidations will happen with a latency from one server to another.
+A difference of even a few seconds can be harmful, so double check this!
+
+To get a rough idea of the clock synchronisation of two servers, simply run
+``python -c 'import time; print(time.time())'`` on both servers at the same
+time. This will give you a number of seconds, and it should be almost the same,
+with a difference inferior to 1 second. This number is independent
+of the time zone.
+
+To keep your clocks synchronised, use the
+`Network Time Protocol <http://en.wikipedia.org/wiki/Network_Time_Protocol>`_.
+
+Replication server
+..................
+
+If you use multiple databases where at least one is a replica of another,
+django-cachalot has no way to know that the replica is modified
+automatically, since it happens outside Django.
+The SQL queries cached for the replica will therefore not be invalidated,
+and you will see some stale queries results.
+
+To fix this problem, you need to tell django-cachalot to also invalidate
+the replica when the primary database is invalidated.
+Suppose your primary database has the ``'default'`` database alias
+in ``DATABASES``, and your replica has the ``'replica'`` alias.
+Use :ref:`the signal <Signal>` and :meth:`cachalot.api.invalidate` this way:
+
+.. code:: python
+
+    from cachalot.api import invalidate
+    from cachalot.signals import post_invalidation
+    from django.dispatch import receiver
+
+    @receiver(post_invalidation)
+    def invalidate_replica(sender, **kwargs):
+        if kwargs['db_alias'] == 'default':
+            invalidate(sender, db_alias='replica')
+
+Multiple cache servers for the same database
+............................................
+
+On large projects, we often end up having multiple Django servers on several
+physical machines. For performance reasons, we generally decide to have a cache
+per server, while the database stays on a single server. But the problem with
+django-cachalot is that it only invalidates the cache configured using
+``CACHALOT_CACHE``. So all caches end up serving stale data.
+
+To avoid this, you need inside each Django server to be able to communicate
+with the rest of the servers in order to invalidate other caches when
+an invalidation occurs. If this is not possible in your situation, you must not
+use django-cachalot. But if you can, each Django server must also have all
+other caches in the ``CACHES`` setting. Then, you need to manually invalidate
+all other caches when an invalidation occurs. Add this to a `models.py` file
+of an installed application:
+
+.. code:: python
+
+    import threading
+
+    from cachalot.api import invalidate
+    from cachalot.signals import post_invalidation
+    from django.dispatch import receiver
+    from django.conf import settings
+
+    SIGNAL_INFO = threading.local()
+
+    @receiver(post_invalidation)
+    def invalidate_other_caches(sender, **kwargs):
+        if getattr(SIGNAL_INFO, 'was_called', False):
+            return
+        db_alias = kwargs['db_alias']
+        for cache_alias in settings.CACHES:
+            if cache_alias == settings.CACHALOT_CACHE:
+                continue
+            SIGNAL_INFO.was_called = True
+            try:
+                invalidate(sender, db_alias=db_alias, cache_alias=cache_alias)
+            finally:
+                SIGNAL_INFO.was_called = False
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
new file mode 100644
index 0000000..b355ad0
--- /dev/null
+++ b/docs/quickstart.rst
@@ -0,0 +1,393 @@
+Quick start
+-----------
+
+Requirements
+............
+
+- Django 2.2, 3.2, 4.0
+- Python 3.7-3.10
+- a cache configured as ``'default'`` with one of these backends:
+
+  - `django-redis <https://github.com/niwinz/django-redis>`_
+  - `memcached <https://docs.djangoproject.com/en/dev/topics/cache/#memcached>`_
+    (using either python-memcached or pylibmc)
+  - `filebased <https://docs.djangoproject.com/en/dev/topics/cache/#filesystem-caching>`_
+  - `locmem <https://docs.djangoproject.com/en/dev/topics/cache/#local-memory-caching>`_
+    (but it’s not shared between processes, see :ref:`locmem limits <Locmem>`)
+
+- one of these databases:
+
+  - PostgreSQL
+  - SQLite
+  - MySQL (but on older versions like MySQL 5.5, django-cachalot has no effect,
+    see :ref:`MySQL limits <MySQL>`)
+
+Usage
+.....
+
+#. ``pip install django-cachalot``
+#. Add ``'cachalot',`` to your ``INSTALLED_APPS``
+#. If you use multiple servers with a common cache server,
+   :ref:`double check their clock synchronisation <multiple servers>`
+#. If you modify data outside Django
+   – typically after restoring a SQL database –,
+   use the :ref:`manage.py command <Command>`
+#. Be aware of :ref:`the few other limits <Limits>`
+#. If you use
+   `django-debug-toolbar <https://github.com/jazzband/django-debug-toolbar>`_,
+   you can add ``'cachalot.panels.CachalotPanel',``
+   to your ``DEBUG_TOOLBAR_PANELS``
+#. Enjoy!
+
+
+.. _Settings:
+
+Settings
+........
+
+``CACHALOT_ENABLED``
+~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``True``
+:Description: If set to ``False``, disables SQL caching but keeps invalidating
+              to avoid stale cache.
+
+``CACHALOT_CACHE``
+~~~~~~~~~~~~~~~~~~
+
+:Default: ``'default'``
+:Description:
+  Alias of the cache from |CACHES|_ used by django-cachalot.
+
+  .. warning::
+     After modifying this setting, you should invalidate the cache
+     :ref:`using the manage.py command <Command>` or :ref:`the API <API>`.
+     Indeed, only the cache configured using this setting is automatically
+     invalidated by django-cachalot – for optimisation reasons. So when you
+     change this setting, you end up on a cache that may contain stale data.
+
+.. |CACHES| replace:: ``CACHES``
+.. _CACHES: https://docs.djangoproject.com/en/dev/ref/settings/#caches
+
+``CACHALOT_DATABASES``
+~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``'supported_only'``
+:Description:
+  List, tuple, set or frozenset of database aliases from |DATABASES|_ against
+  which django-cachalot will do caching. By default, the special value
+  ``'supported_only'`` enables django-cachalot only on supported database
+  engines.
+
+.. |DATABASES| replace:: ``DATABASES``
+.. _DATABASES: https://docs.djangoproject.com/en/dev/ref/settings/#databases
+
+``CACHALOT_TIMEOUT``
+~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``None``
+:Description:
+  Number of seconds during which the cache should consider data as valid.
+  ``None`` means an infinite timeout.
+
+  .. warning::
+     Cache timeouts don’t work in a strict way on most cache backends.
+     A cache might not keep data during the requested timeout:
+     it can keep it in memory during a shorter time than the specified timeout.
+     It can even keep it longer, even if data is not returned when you request it.
+     So **don’t rely on timeouts to limit the size of your database**,
+     you might face some unexpected behaviour.
+     Always set the maximum cache size instead.
+
+``CACHALOT_CACHE_RANDOM``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``False``
+:Description: If set to ``True``, caches random queries
+              (those with ``order_by('?')``).
+
+.. _CACHALOT_INVALIDATE_RAW:
+
+``CACHALOT_INVALIDATE_RAW``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``True``
+:Description:
+  If set to ``False``, disables automatic invalidation on raw
+  SQL queries – read :ref:`raw queries limits <Raw SQL queries>` for more info.
+
+
+.. _CACHALOT_ONLY_CACHABLE_TABLES:
+
+``CACHALOT_ONLY_CACHABLE_TABLES``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``frozenset()``
+:Description:
+  Sequence of SQL table names that will be the only ones django-cachalot
+  will cache. Only queries with a subset of these tables will be cached.
+  The sequence being empty (as it is by default) does not mean that no table
+  can be cached: it disables this setting, so any table can be cached.
+  :ref:`CACHALOT_UNCACHABLE_TABLES` has more weight than this:
+  if you add a table to both settings, it will never be cached.
+  Run ``./manage.py invalidate_cachalot`` after changing this setting.
+
+``CACHALOT_ONLY_CACHABLE_APPS``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``frozenset()``
+:Description:
+  Sequence of Django apps whose associated models will be appended to
+  :ref:`CACHALOT_ONLY_CACHABLE_TABLES`. The rules between
+  :ref:`CACHALOT_UNCACHABLE_TABLES` and :ref:`CACHALOT_ONLY_CACHABLE_TABLES` still
+  apply as this setting only appends the given Django apps' tables on initial
+  Django setup.
+
+
+.. _CACHALOT_UNCACHABLE_TABLES:
+
+``CACHALOT_UNCACHABLE_TABLES``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``frozenset(('django_migrations',))``
+:Description:
+  Sequence of SQL table names that will be ignored by django-cachalot.
+  Queries using a table mentioned in this setting will not be cached.
+  Always keep ``'django_migrations'`` in it, otherwise you may face
+  some issues, especially during tests.
+  Run ``./manage.py invalidate_cachalot`` after changing this setting.
+
+``CACHALOT_UNCACHABLE_APPS``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``frozenset()``
+:Description:
+  Sequence of Django apps whose associated models will be appended to
+  :ref:`CACHALOT_UNCACHABLE_TABLES`. The rules between
+  :ref:`CACHALOT_UNCACHABLE_TABLES` and :ref:`CACHALOT_ONLY_CACHABLE_TABLES` still
+  apply as this setting only appends the given Django apps' tables on initial
+  Django setup.
+
+``CACHALOT_ADDITIONAL_TABLES``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``list()``
+:Description:
+  Sequence of SQL table names that are not included in your Django
+  apps such as unmanaged models. Cachalot caches models that Django
+  does not manage, so if you want to ignore/not-cache those models,
+  then add them here.
+
+``CACHALOT_QUERY_KEYGEN``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``'cachalot.utils.get_query_cache_key'``
+:Description: Python module path to the function that will be used to generate
+              the cache key of a SQL query.
+              Run ``./manage.py invalidate_cachalot``
+              after changing this setting.
+
+``CACHALOT_TABLE_KEYGEN``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``'cachalot.utils.get_table_cache_key'``
+:Description: Python module path to the function that will be used to generate
+              the cache key of a SQL table.
+              Clear your cache after changing this setting (it’s not enough
+              to use ``./manage.py invalidate_cachalot``).
+
+``CACHALOT_FINAL_SQL_CHECK``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Default: ``False``
+:Description:
+    If set to ``True``, the final SQL check will be performed.
+    The `Final SQL check` checks for potentially overlooked tables when looking up involved tables
+    (eg. Ordering by referenced table). See tests for more details
+    (eg. ``test_order_by_field_of_another_table_with_check``).
+
+    Enabling this setting comes with a small performance cost::
+
+        CACHALOT_FINAL_SQL_CHECK=False:
+            mysql      is 1.4× slower then 9.9× faster
+            postgresql is 1.3× slower then 11.7× faster
+            sqlite     is 1.4× slower then 3.0× faster
+            filebased  is 1.4× slower then 9.5× faster
+            locmem     is 1.3× slower then 11.3× faster
+            pylibmc    is 1.4× slower then 8.5× faster
+            pymemcache is 1.4× slower then 7.3× faster
+            redis      is 1.4× slower then 6.8× faster
+
+        CACHALOT_FINAL_SQL_CHECK=True:
+            mysql      is 1.5× slower then 9.0× faster
+            postgresql is 1.3× slower then 10.5× faster
+            sqlite     is 1.4× slower then 2.6× faster
+            filebased  is 1.4× slower then 9.1× faster
+            locmem     is 1.3× slower then 9.9× faster
+            pylibmc    is 1.4× slower then 7.5× faster
+            pymemcache is 1.4× slower then 6.5× faster
+            redis      is 1.5× slower then 6.2× faster
+
+
+
+.. _Command:
+
+``manage.py`` command
+.....................
+
+``manage.py invalidate_cachalot`` is available to invalidate all the cache keys
+set by django-cachalot. If you run it without any argument, it invalidates all
+models on all caches and all databases. But you can specify what applications
+or models are invalidated, and on which cache or database.
+
+Examples:
+
+``./manage.py invalidate_cachalot auth``
+    Invalidates all models from the 'auth' application.
+``./manage.py invalidate_cachalot your_app auth.User``
+    Invalidates all models from the 'your_app' application, but also
+    the ``User`` model from the 'auth' application.
+``./manage.py invalidate_cachalot -c redis -p postgresql``
+    Invalidates all models,
+    but only for the database configured with the 'postgresql' alias,
+    and only for the cache configured with the 'redis' alias.
+
+
+.. _Template utils:
+
+Template utils
+..............
+
+`Caching template fragments <https://docs.djangoproject.com/en/dev/topics/cache/#template-fragment-caching>`_
+can be extremely powerful to speedup a Django application.  However, it often
+means you have to adapt your models to get a relevant cache key, typically
+by adding a timestamp that refers to the last modification of the object.
+
+But modifying your models and caching template fragments leads
+to stale contents most of the time. There’s a simple reason to that: we rarely
+only display the data from one model, we often want to display related data,
+such as the number of books written by someone, display a quote from a book
+of this author, display similar authors, etc. In such situations,
+**it’s impossible to cache template fragments and avoid stale rendered data**.
+
+Fortunately, django-cachalot provides an easy way to fix this issue,
+by simply checking when was the last time data changed in the given models
+or tables.  The API function
+:meth:`get_last_invalidation <cachalot.api.get_last_invalidation>` does that,
+and we provided a ``get_last_invalidation`` template tag to directly
+use it in templates.  It works exactly the same as the API function.
+
+Django template tag
+~~~~~~~~~~~~~~~~~~~
+
+Example of a quite heavy nested loop with a lot of SQL queries
+(considering no prefetch has been done)::
+
+    {% load cachalot cache %}
+
+    {% get_last_invalidation 'auth.User' 'library.Book' 'library.Author' as last_invalidation %}
+    {% cache 3600 short_user_profile last_invalidation %}
+      {{ user }} has borrowed these books:
+      {% for book in user.borrowed_books.all %}
+        <div class="book">
+          {{ book }} ({{ book.pages.count }} pages)
+          <span class="authors">
+            {% for author in book.authors.all %}
+              {{ author }}{% if not forloop.last %},{% endif %}
+            {% endfor %}
+          </span>
+        </div>
+      {% endfor %}
+    {% endcache %}
+
+``cache_alias`` and ``db_alias`` keywords arguments of this template tag
+are also available (see
+:meth:`cachalot.api.get_last_invalidation`).
+
+Jinja2 statement and function
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A Jinja2 extension for django-cachalot can be used, simply add
+``'cachalot.jinja2ext.cachalot',`` to the ``'extensions'`` list of the ``OPTIONS``
+dict in the Django ``TEMPLATES`` settings.
+
+It provides:
+
+- The API function
+  :meth:`get_last_invalidation <cachalot.api.get_last_invalidation>` directly
+  available as a function anywhere in Jinja2.
+- An Jinja2 statement equivalent to the ``cache`` template tag of Django.
+
+The ``cache`` does the same thing as its Django template equivalent,
+except that ``cache_key`` and ``timeout`` are optional keyword arguments, and
+you need to add commas between arguments. When unspecified, ``cache_key`` is
+generated from the template filename plus the statement line number, and
+``timeout`` defaults to infinite.  To specify which cache should store the
+saved content, use the ``cache_alias`` keyword argument.
+
+Same example than above, but for Jinja2::
+
+    {% cache get_last_invalidation('auth.User', 'library.Book', 'library.Author'),
+             cache_key='short_user_profile', timeout=3600 %}
+      {{ user }} has borrowed these books:
+      {% for book in user.borrowed_books.all() %}
+        <div class="book">
+          {{ book }} ({{ book.pages.count() }} pages)
+          <span class="authors">
+            {% for author in book.authors.all() %}
+              {{ author }}{% if not loop.last %},{% endif %}
+            {% endfor %}
+          </span>
+        </div>
+      {% endfor %}
+    {% endcache %}
+
+
+.. _Signal:
+
+Signal
+......
+
+``cachalot.signals.post_invalidation`` is available if you need to do something
+just after a cache invalidation (when you modify something in a SQL table).
+``sender`` is the name of the SQL table invalidated, and a keyword argument
+``db_alias`` explains which database is affected by the invalidation.
+Be careful when you specify ``sender``, as it is sensible to string type.
+To be sure, use ``Model._meta.db_table``.
+
+This signal is not directly triggered during transactions,
+it waits until the current transaction ends.  This signal is also triggered
+when invalidating using the API or the ``manage.py`` command.  Be careful
+when using multiple databases, if you invalidate all databases by simply
+calling ``invalidate()``, this signal will be triggered one time
+for each database and for each model.  If you have 3 databases and 20 models,
+``invalidate()`` will trigger the signal 60 times.
+
+Example:
+
+.. code:: python
+
+    from cachalot.signals import post_invalidation
+    from django.dispatch import receiver
+    from django.core.mail import mail_admins
+    from django.contrib.auth import *
+
+    # This prints a message to the console after each table invalidation
+    def invalidation_debug(sender, **kwargs):
+        db_alias = kwargs['db_alias']
+        print('%s was invalidated in the DB configured as %s'
+              % (sender, db_alias))
+
+    post_invalidation.connect(invalidation_debug)
+
+    # Using the `receiver` decorator is just a nicer way
+    # to write the same thing as `signal.connect`.
+    # Here we specify `sender` so that the function is executed only if
+    # the table invalidated is the one specified.
+    # We also connect it several times to be executed for several senders.
+    @receiver(post_invalidation, sender=User.groups.through._meta.db_table)
+    @receiver(post_invalidation, sender=User.user_permissions.through._meta.db_table)
+    @receiver(post_invalidation, sender=Group.permissions.through._meta.db_table)
+    def warn_admin(sender, **kwargs):
+        mail_admins('User permissions changed',
+                    'Someone probably gained or lost Django permissions.')
diff --git a/docs/reporting.rst b/docs/reporting.rst
new file mode 100644
index 0000000..bcf5158
--- /dev/null
+++ b/docs/reporting.rst
@@ -0,0 +1,18 @@
+.. _Reporting:
+
+Bug reports, questions, discussion, new features
+------------------------------------------------
+
+- If you spotted **a bug**, please file a precise bug report
+  `on GitHub <https://github.com/noripyt/django-cachalot/issues>`_
+- If you have **a question** on how django-cachalot works
+  or to **simply discuss**,
+  `chat with us on Discord <https://discord.gg/WFGFBk8rSU>`_.
+- If you want **to add a feature**:
+
+  - if you have an idea on how to implement it, you can fork the project
+    and send a pull request, but **please open an issue first**, because
+    someone else could already be working on it
+  - if you’re sure that it’s a must-have feature, open an issue
+  - if it’s just a vague idea, please
+    `ask on Discord <https://discord.gg/WFGFBk8rSU>`_ first
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..188c6d1
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,2 @@
+Sphinx==1.6.2
+sphinx-rtd-theme==0.2.4
diff --git a/docs/todo.rst b/docs/todo.rst
new file mode 100644
index 0000000..1a9b019
--- /dev/null
+++ b/docs/todo.rst
@@ -0,0 +1,12 @@
+What could still be done
+------------------------
+
+- Cache raw queries (may not be possible due to database cursors
+  being written in C)
+- Allow setting ``CACHALOT_CACHE`` to ``None`` in order to disable django-cachalot
+  persistence. SQL queries would only be cached during transactions, so setting
+  ``ATOMIC_REQUESTS`` to ``True`` would cache SQL queries only during
+  a request-response cycle. This would be useful for websites with a lot of
+  invalidations (social network for example), but with several times the same
+  SQL queries in a single response-request cycle, as it occurs in Django admin.
+- Create a command to check clock synchronisation between remote servers
diff --git a/requirements.txt b/requirements.txt
index 2fd01c7..9e8cdd1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1 @@
-Django>=2.2,<3.3
+Django>=2.2,<4.1
diff --git a/runtests.py b/runtests.py
new file mode 100755
index 0000000..14496e3
--- /dev/null
+++ b/runtests.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+import os
+import sys
+import django
+
+
+if __name__ == '__main__':
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings')
+    django.setup()
+    from django.test.runner import DiscoverRunner
+    test_runner = DiscoverRunner(verbosity=2, interactive=False)
+    failures = test_runner.run_tests(['cachalot.tests'])
+    if failures:
+        sys.exit(failures)
diff --git a/runtests_urls.py b/runtests_urls.py
new file mode 100644
index 0000000..b11ff8c
--- /dev/null
+++ b/runtests_urls.py
@@ -0,0 +1,13 @@
+import debug_toolbar
+from django.urls import re_path, include
+from django.http import HttpResponse
+
+
+def empty_page(request):
+    return HttpResponse('<body></body>')
+
+
+urlpatterns = [
+    re_path(r'^$', empty_page),
+    re_path(r'^__debug__/', include(debug_toolbar.urls)),
+]
diff --git a/settings.py b/settings.py
new file mode 100644
index 0000000..0c1a89f
--- /dev/null
+++ b/settings.py
@@ -0,0 +1,165 @@
+import os
+
+from django import VERSION as __DJ_V
+
+
+DATABASES = {
+    'sqlite3': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': 'cachalot.sqlite3',
+    },
+    'postgresql': {
+        'ENGINE': 'django.db.backends.postgresql',
+        'NAME': 'cachalot',
+        'USER': 'cachalot',
+        'HOST': '127.0.0.1',
+    },
+    'mysql': {
+        'ENGINE': 'django.db.backends.mysql',
+        'NAME': 'cachalot',
+        'USER': 'root',
+        'HOST': '127.0.0.1',
+    },
+}
+if 'MYSQL_PASSWORD' in os.environ:
+    DATABASES['mysql']['PASSWORD'] = os.environ['MYSQL_PASSWORD']
+if 'POSTGRES_PASSWORD' in os.environ:
+    DATABASES['postgresql']['PASSWORD'] = os.environ['POSTGRES_PASSWORD']
+for alias in DATABASES:
+    if 'TEST' not in DATABASES[alias]:
+        test_db_name = 'test_' + DATABASES[alias]['NAME']
+        DATABASES[alias]['TEST'] = {'NAME': test_db_name}
+
+DATABASES['default'] = DATABASES.pop(os.environ.get('DB_ENGINE', 'sqlite3'))
+DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
+DATABASE_ROUTERS = ['cachalot.tests.db_router.PostgresRouter']
+
+CACHES = {
+    'redis': {
+        'BACKEND': 'django_redis.cache.RedisCache',
+        'LOCATION': 'redis://127.0.0.1:6379/0',
+        'OPTIONS': {
+            # Since we are using both Python 2 & 3 in tests, we need to use
+            # a compatible pickle version to avoid unpickling errors when
+            # running a Python 2 test after a Python 3 test.
+            'PICKLE_VERSION': 2,
+        },
+    },
+    'memcached': {
+        'BACKEND': 'django.core.cache.backends.memcached.'
+                   + ('PyMemcacheCache' if __DJ_V[0] > 2
+                      and (__DJ_V[1] > 1 or __DJ_V[0] > 3) else 'MemcachedCache'),
+        'LOCATION': '127.0.0.1:11211',
+    },
+    'locmem': {
+        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+        'OPTIONS': {
+            # We want that limit to be infinite, otherwise we can’t
+            # reliably count the number of SQL queries executed in tests.
+
+            # In this context, 10e9 is enough to be considered
+            # infinite.
+            'MAX_ENTRIES': 10e9,
+        }
+    },
+    'filebased': {
+        'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
+        'LOCATION': '/tmp/django_cache',
+        'OPTIONS': {
+            'MAX_ENTRIES': 10e9,  # (See locmem)
+        },
+    }
+}
+
+try:
+    import pylibmc
+except ImportError:
+    pass
+else:
+    CACHES['pylibmc'] = {
+        'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
+        'LOCATION': '127.0.0.1:11211',
+    }
+
+DEFAULT_CACHE_ALIAS = os.environ.get('CACHE_BACKEND', 'locmem')
+CACHES['default'] = CACHES.pop(DEFAULT_CACHE_ALIAS)
+if DEFAULT_CACHE_ALIAS == 'memcached' and 'pylibmc' in CACHES:
+    del CACHES['pylibmc']
+elif DEFAULT_CACHE_ALIAS == 'pylibmc':
+    del CACHES['memcached']
+
+INSTALLED_APPS = [
+    'cachalot',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.postgres',  # Enables the unaccent lookup.
+]
+
+MIGRATION_MODULES = {
+    'cachalot': 'cachalot.tests.migrations',
+}
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [],
+        'APP_DIRS': True,
+    },
+    {
+        'BACKEND': 'django.template.backends.jinja2.Jinja2',
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'extensions': [
+                'cachalot.jinja2ext.cachalot',
+            ],
+        },
+    }
+]
+
+MIDDLEWARE = []
+PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
+SECRET_KEY = 'it’s not important in tests but we have to set it'
+
+USE_TZ = False  # Time zones are not supported by MySQL, we only enable it in tests when needed.
+TIME_ZONE = 'UTC'
+
+CACHALOT_ENABLED = True
+
+#
+# Settings for django-debug-toolbar
+#
+
+# We put django-debug-toolbar before to reproduce the conditions of this issue:
+# https://github.com/noripyt/django-cachalot/issues/62
+INSTALLED_APPS = [
+    'debug_toolbar',
+] + INSTALLED_APPS + ['django.contrib.staticfiles']
+
+DEBUG_TOOLBAR_PANELS = [
+    'debug_toolbar.panels.versions.VersionsPanel',
+    'debug_toolbar.panels.timer.TimerPanel',
+    'debug_toolbar.panels.settings.SettingsPanel',
+    'debug_toolbar.panels.headers.HeadersPanel',
+    'debug_toolbar.panels.request.RequestPanel',
+    'debug_toolbar.panels.sql.SQLPanel',
+    'debug_toolbar.panels.staticfiles.StaticFilesPanel',
+    'debug_toolbar.panels.templates.TemplatesPanel',
+    'debug_toolbar.panels.cache.CachePanel',
+    'debug_toolbar.panels.signals.SignalsPanel',
+    'debug_toolbar.panels.logging.LoggingPanel',
+    'debug_toolbar.panels.redirects.RedirectsPanel',
+    'cachalot.panels.CachalotPanel',
+]
+
+DEBUG_TOOLBAR_CONFIG = {
+    # Django’s test client sets wsgi.multiprocess to True inappropriately.
+    'RENDER_PANELS': False,
+}
+
+MIDDLEWARE += [
+    'debug_toolbar.middleware.DebugToolbarMiddleware',
+]
+
+INTERNAL_IPS = ['127.0.0.1']
+ROOT_URLCONF = 'runtests_urls'
+STATIC_URL = '/static/'
diff --git a/setup.py b/setup.py
index a8fd293..8f1a12a 100755
--- a/setup.py
+++ b/setup.py
@@ -26,13 +26,13 @@ setup(
         'License :: OSI Approved :: BSD License',
         'Operating System :: OS Independent',
         'Framework :: Django :: 2.2',
-        'Framework :: Django :: 3.1',
         'Framework :: Django :: 3.2',
+        'Framework :: Django :: 4.0',
         'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.6',
         'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: 3.8',
         'Programming Language :: Python :: 3.9',
+        'Programming Language :: Python :: 3.10',
         'Topic :: Internet :: WWW/HTTP',
     ],
     license='BSD',
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..780eb86
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,54 @@
+[tox]
+envlist =
+    py{37,38,39}-django2.2-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
+    py{37,38,39,310}-django3.2-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
+    py{38,39,310}-django4.0-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
+    py{38,39,310}-djangomain-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
+
+[testenv]
+basepython =
+    py37: python3.7
+    py38: python3.8
+    py39: python3.9
+    py310: python3.10
+deps =
+    django2.2: Django>=2.2,<2.3
+    django3.2: Django>=3.2,<4.0
+    django4.0: Django>=4.0,<4.1
+    djangomain: https://github.com/django/django/archive/main.tar.gz
+    psycopg2-binary>=2.8,<2.9
+    mysqlclient
+    django-redis
+    python-memcached
+    pymemcache
+    pylibmc
+    pytz
+    Jinja2
+    django-debug-toolbar
+    beautifulsoup4
+    coverage
+setenv =
+    sqlite3:    DB_ENGINE=sqlite3
+    postgresql: DB_ENGINE=postgresql
+    mysql:      DB_ENGINE=mysql
+    locmem:     CACHE_BACKEND=locmem
+    filebased:  CACHE_BACKEND=filebased
+    redis:      CACHE_BACKEND=redis
+    memcached:  CACHE_BACKEND=memcached
+    pylibmc:    CACHE_BACKEND=pylibmc
+commands =
+    coverage run -a --source=cachalot ./runtests.py
+
+[gh-actions]
+python =
+    3.7: py37
+    3.8: py38
+    3.9: py39
+    3.10: py310
+
+[gh-actions:env]
+DJANGO =
+    2.2: django2.2
+    3.2: django3.2
+    4.0: django4.0
+    main: djangomain