HackSoft logo
  • Approach
  • Case Studies
  • Team
  • Company
  • Services
      Custom Software Development ConsultingAI Integrations
  • Solutions
  • Open Source
  • Blog

Need help with your Django project?

Check our django services

Timestamps in Django - exploring auto_now, auto_now_add and default

Radoslav Georgiev
Jan 30, 2023
Categories:Django
This blog post was inspired by the following pull request towards our Django Styleguide

When it comes to using auto_now and auto_now_add for the usual created_at / updated_at timestamps - I've always looked at the Django documentation, to figure out the exact behavior.

And the thing that was always lacking was an example. So this article should serve as an example!

But first, lets start with the problem we want to solve.

Lets say we have the following BaseModel definition:

class BaseModel(models.Model):
    created_at = models.DateTimeField()
    updated_at = models.DateTimeField()

    class Meta:
        abstract = True

Now, we might want to leverage auto_now, auto_now_add and/or default, in order for Django to take care of the values for us.

At the time of writing, in our Styleguide Example Project, the definition of the BaseModel is as follows (ignoring indexes, since they are not the focus of the article):

class BaseModel(models.Model):
    created_at = models.DateTimeField(default=timezone.now)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

Which looks strange - we have default for created_at, but auto_now for updated_at 🤔

In order to decide what we actually need, we are going to explore how the different options behave:

  1. Using auto_now_add and auto_now.
  2. Mixing default and auto_now.
  3. Using only default

The approach we are going to take is:

  1. Have a simple model for each of the options.
  2. Assert the behavior writing tests.

Lets start with the models:

from django.db import models
from django.utils import timezone


class TimestampsWithAuto(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)


class TimestampsWithAutoAndDefault(models.Model):
    created_at = models.DateTimeField(default=timezone.now)
    updated_at = models.DateTimeField(auto_now=True)


class TimestampsWithDefault(models.Model):
    created_at = models.DateTimeField(default=timezone.now)
    updated_at = models.DateTimeField(default=timezone.now)

Using auto_now_add and auto_now

Perhaps, this is the case you can find most often on the internet & thus - the one that's most often copy-pasted in projects.

class TimestampsWithAuto(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

What's the behavior here?

Reading from Django's documentation, we have the following:

For auto_now:

Automatically set the field to now every time the object is saved. Useful for “last-modified” timestamps. Note that the current date is always used; it’s not just a default value that you can override.

For auto_now_add:

Automatically set the field to now when the object is first created. Useful for creation of timestamps. Note that the current date is always used; it’s not just a default value that you can override. So even if you set a value for this field when creating the object, it will be ignored.

So if we use both auto_now and auto_now_add, we'll get the following behavior:

  1. created_at is going to be populated with timezone.now() upon creation. As a reference, check this - https://github.com/django/django/blob/main/django/db/models/fields/__init__.py#L1573
  2. updated_at is going to be populated with timezone.now() whenever the object is saved via .save().
  3. Even if you explicitly pass values to either created_at or updated_at, those values are going to be ignored!

The 3rd point is extremely important. This means - if we use auto_now and auto_now_add, we won't have control over those values.

Lets illustrate this behavior with a simple test against the model (for more on what to test in a Django project, you can watch this talk from DjangoCon Europe 2022 - https://youtu.be/PChaEAIsQls)

from datetime import timedelta

from django.test import TestCase
from django.utils import timezone

from styleguide_example.common.models import (
    TimestampsWithAuto,
)


class TimestampsTests(TestCase):
    def test_timestamps_with_auto_behavior(self):
        """
        Timestamps are set automatically
        """
        obj = TimestampsWithAuto()
        obj.full_clean()
        obj.save()

        self.assertIsNotNone(obj.created_at)
        self.assertIsNotNone(obj.updated_at)

        self.assertNotEqual(obj.created_at, obj.updated_at)

        """
        Timestamps cannot be overridden
        """
        timestamp = timezone.now() - timedelta(days=1)

        obj = TimestampsWithAuto(created_at=timestamp, updated_at=timestamp)
        obj.full_clean()
        obj.save()

        self.assertNotEqual(timestamp, obj.created_at)
        self.assertNotEqual(timestamp, obj.updated_at)

        """
        updated_at gets auto updated, while created_at stays the same
        """
        obj = TimestampsWithAuto()
        obj.full_clean()
        obj.save()

        original_created_at = obj.created_at
        original_updated_at = obj.updated_at

        obj.save()
        # Get a fresh object
        obj = TimestampsWithAuto.objects.get(id=obj.id)

        self.assertEqual(original_created_at, obj.created_at)
        self.assertNotEqual(original_updated_at, obj.updated_at)

The tests are passing, confirming the described behavior above.

Mixing default and auto_now

Lets look at the next example:

class TimestampsWithAutoAndDefault(models.Model):
    created_at = models.DateTimeField(default=timezone.now)
    updated_at = models.DateTimeField(auto_now=True)

At first, this looks a bit strange, since it relies on default for created_at.

Here's the behavior of default, according to Django's documentation:

The default value is used when new model instances are created and a value isn’t provided for the field. When the field is a primary key, the default is also used when the field is set to None.

Okay, looks like it's going to give us the same behavior as with auto_now_add=True, with one exception - we can override the value for created_at, if we want to.

So if we use both default and auto_now, we'll get the following behavior:

  1. created_at is going to be populated with timezone.now() upon creation.
  2. updated_at is going to be populated with timezone.now() whenever the object is saved via .save().
  3. We can pass a different value for created_at.
  4. Even if we explicitly pass value to updated_at, it's going to be ignored.

Lets illustrate this with tests:

from datetime import timedelta

from django.test import TestCase
from django.utils import timezone

from styleguide_example.common.models import (
    TimestampsWithAutoAndDefault,
)


class TimestampsTests(TestCase):
    def test_timestamps_with_mixed_behavior(self):
        """
        Timestamps are set automatically / by default
        """
        obj = TimestampsWithAutoAndDefault()
        obj.full_clean()
        obj.save()

        self.assertIsNotNone(obj.created_at)
        self.assertIsNotNone(obj.updated_at)

        self.assertNotEqual(obj.created_at, obj.updated_at)

        """
        Some timestamps can be overridden
        """
        timestamp = timezone.now() - timedelta(days=1)

        obj = TimestampsWithAutoAndDefault(created_at=timestamp, updated_at=timestamp)
        obj.full_clean()
        obj.save()

        # This is default
        self.assertEqual(timestamp, obj.created_at)
        # This is auto_now
        self.assertNotEqual(timestamp, obj.updated_at)

        """
        updated_at gets auto updated, while created_at stays the same
        """
        obj = TimestampsWithAutoAndDefault()
        obj.full_clean()
        obj.save()

        original_created_at = obj.created_at
        original_updated_at = obj.updated_at

        obj.save()
        # Get a fresh object
        obj = TimestampsWithAutoAndDefault.objects.get(id=obj.id)

        self.assertEqual(original_created_at, obj.created_at)
        self.assertNotEqual(original_updated_at, obj.updated_at)

Again, this has a similar behavior as our first example, with the exception that we can now provide values for created_at!

Using only default

Since we are here, lets explore the behavior of having both timestamps using default:

class TimestampsWithDefault(models.Model):
    created_at = models.DateTimeField(default=timezone.now)
    updated_at = models.DateTimeField(default=timezone.now)

Following the documentation for default, the behavior should be the following:

  1. created_at is getting a timezone.now() value upon creation.
  2. updated_at is getting a timezone.now() value upon creation.
  3. Unless the value is explicitly None, both created_at and updated_at are not going to be updated upon .save().
  4. We have flexibility over the values for both created_at and updated_at, if we don't want to use the defaults.

Lets illustrate that with tests:

from datetime import timedelta

from django.test import TestCase
from django.utils import timezone

from styleguide_example.common.models import (
    TimestampsWithDefault,
)


class TimestampsTests(TestCase):
    def test_timestamps_with_default_behavior(self):
        """
        Timestamps are set by default
        """
        obj = TimestampsWithDefault()
        obj.full_clean()
        obj.save()

        self.assertIsNotNone(obj.created_at)
        self.assertIsNotNone(obj.updated_at)

        self.assertNotEqual(obj.created_at, obj.updated_at)

        """
        Both timestamps can be overridden
        """
        timestamp = timezone.now() - timedelta(days=1)

        obj = TimestampsWithDefault(created_at=timestamp, updated_at=timestamp)
        obj.full_clean()
        obj.save()

        self.assertEqual(timestamp, obj.created_at)
        self.assertEqual(timestamp, obj.updated_at)
        # And by transitivity
        self.assertEqual(obj.created_at, obj.updated_at)

        """
        created_at / updated_at are not auto updated
        """
        obj = TimestampsWithDefault()
        obj.full_clean()
        obj.save()

        original_created_at = obj.created_at
        original_updated_at = obj.updated_at

        obj.save()
        # Get a fresh object
        obj = TimestampsWithDefault.objects.get(id=obj.id)

        self.assertEqual(original_created_at, obj.created_at)
        self.assertEqual(original_updated_at, obj.updated_at)

The tests confirm the described behavior above.

Since now we know the behavior of all 3 cases, we can make a better decision, based on our needs.

The opinionated approach

By the looks of it, all approaches from above can get the job done, but also, they might have some downsides.

For example, setting updated_at on new objects (with a value that's slightly different from created_at ) looks like a potential downside.

Lets define our desired behavior and figure out how to achieve it:

  1. created_at should have a default, unless another value is provided (having the flexibility to change).
  2. updated_at should be empty (null / None) for new objects.
  3. updated_at should have a default value on updates, unless another value is provided (having the flexibility to change).

We can achieve the first 2 points with the following model definition:

class TimestampsOpinionated(models.Model):
    created_at = models.DateTimeField(default=timezone.now)
    updated_at = models.DateTimeField(blank=True, null=True)

Lets illustrate the behavior with tests:

from datetime import timedelta

from django.test import TestCase
from django.utils import timezone

from styleguide_example.common.models import (
    TimestampsOpinionated
)


class TimestampsTests(TestCase):
    def test_timestamps_with_opinionated_behavior(self):
        """
        created_at is only set by default
        """
        obj = TimestampsOpinionated()
        obj.full_clean()
        obj.save()

        self.assertIsNotNone(obj.created_at)
        self.assertIsNone(obj.updated_at)

        """
        Both timestamps can be overridden
        """
        timestamp = timezone.now() - timedelta(days=1)

        obj = TimestampsOpinionated(created_at=timestamp, updated_at=timestamp)
        obj.full_clean()
        obj.save()

        self.assertEqual(timestamp, obj.created_at)
        self.assertEqual(timestamp, obj.updated_at)
        # And by transitivity
        self.assertEqual(obj.created_at, obj.updated_at)

        """
        updated_at is not auto updated, created_at stays the same
        """
        obj = TimestampsOpinionated()
        obj.full_clean()
        obj.save()

        original_created_at = obj.created_at
        original_updated_at = obj.updated_at

        obj.save()
        # Get a fresh object
        obj = TimestampsOpinionated.objects.get(id=obj.id)

        self.assertEqual(original_created_at, obj.created_at)
        self.assertEqual(original_updated_at, obj.updated_at)

For the 3rd point, we can achieve that in 2 major ways:

  1. Overriding the model's save method with the aim to check if this is a new model instance or not. Additionally, we'll need to check if updated_at is "dirty" or not, in order to decide if we want to use the provided value or rely on a default one. A potential library for "dirty" checking could be https://github.com/romgar/django-dirtyfields
  2. Leveraging the service layer. What we can do is to make the model_update service helper a bit smarter - taking care of updated_at for us.

A potential implementation for model_update, take takes into account updated_at, can be found here:

  1. https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/common/services.py
  2. And it's a good idea to read the tests here - https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/common/tests/services/test_model_update.py

Hopefully, after reading this article, you now have more clarity around timestmaps, auto_now, auto_now_add and default!

Need help with your Django project?

Check our django services
HackSoft logo
Your development partner beyond code.