Avoiding missed auto_now fields in Django when using update_or_create

Avoiding missed auto_now fields in Django when using update_or_create
Photo by Markus Winkler / Unsplash

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.