Avoiding missed auto_now fields in Django when using update_or_create
An oft-used pattern in the Django world is to add something like the following to some models:
class SomeModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
These are great properties to add to models, because they help to answer a lot of questions that come up by various stakeholders. I put them on to most of the models I create as a given. In fact, a lot of the time I put it onto an abstract model that I then inherit from somewhere else, like so:
class SomeAbstractModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class ConcreteModel(SomeAbstractModel):
foo = models.CharField(max_length=420)
Recently, though, I was alerted to an issue where the use of ConcreteModel.objects.update_or_create()
was not resulting in instances of ConcreteModel
having updated_at
reflect the actual update time. I thought this was odd, as update_or_create()
does indeed call save()
and as such should respect the auto_now
aspect of the DateTimeField
.
It turns out that the problem was this piece of the code of update_or_create
itself:
update_fields = set(update_defaults)
concrete_field_names = self.model._meta._non_pk_concrete_field_names
# update_fields does not support non-concrete fields.
if concrete_field_names.issuperset(update_fields):
# Add fields which are set on pre_save(), e.g. auto_now fields.
# This is to maintain backward compatibility as these fields
# are not updated unless explicitly specified in the
# update_fields list.
pk_fields = self.model._meta.pk_fields
for field in self.model._meta.local_concrete_fields:
if not (
field in pk_fields or field.__class__.pre_save is Field.pre_save
):
update_fields.add(field.name)
if field.name != field.attname:
update_fields.add(field.attname)
obj.save(using=self.db, update_fields=update_fields)
else:
obj.save(using=self.db)
The long and short of it is that the inherited fields, including updated_at
are not included in the local_concrete_fields
and as such aren't added to update_fields
, meaning that updated_at
is never... well... updated.
Luckily there's a relatively simple way around that, and here's what I'm going with for now:
class CreatedAtUpdatedAtModel(models.Model):
"""
An abstract model which implements the often used `created_at` and `updated_at` fields.
Overrides `save()` to ensure that `updated_at` is included in `update_fields` when subclasses
use `update_or_create` as this is otherwise missed.
"""
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
def save(self, update_fields=None, *args, **kwargs):
if update_fields and "updated_at" not in update_fields:
match update_fields:
case list():
update_fields.append("updated_at")
case set():
update_fields.add("updated_at")
case _:
logging.error("Update fields was neither a list or a set")
return super().save(*args, **kwargs)
Now I can derive my inherited models from CreatedAtUpdatedAtModel
and know that they're going to have an accurate updated_at
value, even when used with something like update_or_create
. I hope you can find that solution useful too.