Question: Let's say I have the following Django models: class Team(models.Model): users = models.ManyToManyField(User, through="TeamUser") @cached_property def total_points(self): return self.teamuser_set.aggregate(models.Sum("points"))["points__sum"] or 0 class TeamUser(models.Model): team = models.ForeignKey(Team, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) points = models.IntegerField()  I want to create a signal handler that will invalidate the team.total_points cache when TeamUser object is created/updated/deleted. I started with the following signal handler. Note the Django docs recommended the del instance.prop call. @receiver(post_save, sender=models.TeamUser) @receiver(post_delete, sender=models.TeamUser) def invalidate_cache(**kwargs): try: del kwargs["instance"].team.total_points except AttributeError: pass  And some tests. Note I'm using pytest-django. def test_create_team_users(django_assert_num_queries): user = factories.UserFactory() team = factories.TeamFactory() assert team.total_points == 0 with django_assert_num_queries(1): TeamUser.objects.create(team=team, user=user, points=2) assert team.total_points == 2 with django_assert_num_queries(1): TeamUser.objects.create(team=team, user=user, points=3) assert team.total_points == 5 def test_delete_all_team_users(django_assert_num_queries): user = factories.UserFactory() team = factories.TeamFactory() for _ in range(10): TeamUser.objects.create(team=team, user=user, points=2) with django_assert_num_queries(2): TeamUser.objects.all().delete() assert team.total_points == 0  The test_create_team_users test passed but the test_delete_all_team_users test failed because the query count is 12 instead of 2. Yikes! Looks like an N+1 query. To prevent this, I updated my signal handler to only invalidate the team.total_points cache if the user object is cached on the TeamUser object. I found the is_cached method in this SO answer. @receiver(post_save, sender=models.TeamUser) @receiver(post_delete, sender=models.TeamUser) def invalidate_cache(sender, instance, **kwargs): if sender.team.is_cached(instance): try: del instance.team.total_points except AttributeError: pass  Now both tests pass! Does this correctly invalidate the team.total_points cache in all cases? Is there an edge case I'm missing?

Answer: I think there is a misconception here. cached_property isn't actually a persisting database cache or a Redis cache. It lives and dies within one instance. This usually means a single request. It will be already refreshed in the next request. It's reason for existence is to not call an expensive method more than once, within the same instance. For example, if you had a template that used this team.total_points next to a team's name, but also around footnotes and maybe as html title attribute for the picture so that it shows when you hover over team's picture, with @property, this method would get called 3 times, with @cached_property, this method would only get called once. But it's not going to last for the next request or next page refresh. So "invalidating" it in a signal is most possibly unneeded. Not only unneeded but it won't even work for some cases. This would fail for example: def test_delete_all_team_users(django_assert_num_queries): user = factories.UserFactory() team = factories.TeamFactory() for _ in range(10): TeamUser.objects.create(team=team, user=user, points=2) # this assert will pass, but while doing it, total_points will be cached assert team.total_points == 20 with django_assert_num_queries(2): TeamUser.objects.all().delete() # this will fail now because team.total_points was cached before and the cache isn't cleared assert team.total_points == 0  This is because "instance.team" in the post_delete callback isn't the same team instance as the one you have in the test function. These are separate team instances that are populated by the same database row. So a signal can't work here to delete this cache. It's useless. Instead of a post_delete signal here, you should delete team.total_points within the test function itself, after deleting TeamUsers or before asserting. Then it would pass. def test_delete_all_team_users(django_assert_num_queries): user = factories.UserFactory() team = factories.TeamFactory() for _ in range(10): TeamUser.objects.create(team=team, user=user, points=2) assert team.total_points == 20 with django_assert_num_queries(2): TeamUser.objects.all().delete() del team.total_points # this will pass now assert team.total_points == 0  This too would pass with the signal because django is populating instance.team with the same team instance as the one you have in the test function in here. def test_delete_all_team_users(django_assert_num_queries): user = factories.UserFactory() team = factories.TeamFactory() for _ in range(10): TeamUser.objects.create(team=team, user=user, points=2) assert team.total_points == 20 with django_assert_num_queries(2): team.teamuser_set.all().delete() # TeamUser.objects.all().delete() # this will pass too. because signal cleared the cache this time. assert team.total_points == 0  I should also say that post_delete signal here is also costing you an extra database hit. if you get rid of it, TeamUser.objects.all().delete() should only hit your database once, instead of twice. But you would need to manually delete the cache after you do something that will change it. Besides the test functions, it will very rarely occur so I don't think it'll be much of a miss. Edit: This is not only for deleting but if you are not passing the team instance itself, but passing an id of it perhaps, post_save signals won't work as you expect either. This too will fail: def test_create_team_users(django_assert_num_queries): user = factories.UserFactory() team = factories.TeamFactory() assert team.total_points == 0 with django_assert_num_queries(1): # I changed this so that we're passing team_id instead of team # In post_save, team instance will be different. TeamUser.objects.create(team_id=team.id, user=user, points=2) # so this assert will fail assert team.total_points == 2