Need help with your Django project?
Check our django servicesTimestamps in Django - exploring auto_now, auto_now_add and default
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:
- Using
auto_now_add
andauto_now
. - Mixing
default
andauto_now
. - Using only
default
The approach we are going to take is:
- Have a simple model for each of the options.
- 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:
created_at
is going to be populated withtimezone.now()
upon creation. As a reference, check this - https://github.com/django/django/blob/main/django/db/models/fields/__init__.py#L1573updated_at
is going to be populated withtimezone.now()
whenever the object is saved via.save()
.- Even if you explicitly pass values to either
created_at
orupdated_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:
created_at
is going to be populated withtimezone.now()
upon creation.updated_at
is going to be populated withtimezone.now()
whenever the object is saved via.save()
.- We can pass a different value for
created_at
. - 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:
created_at
is getting atimezone.now()
value upon creation.updated_at
is getting atimezone.now()
value upon creation.- Unless the value is explicitly
None
, bothcreated_at
andupdated_at
are not going to be updated upon.save()
. - We have flexibility over the values for both
created_at
andupdated_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:
created_at
should have a default, unless another value is provided (having the flexibility to change).updated_at
should be empty (null
/None
) for new objects.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:
- 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 ifupdated_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 - Leveraging the service layer. What we can do is to make the
model_update
service helper a bit smarter - taking care ofupdated_at
for us.
A potential implementation for model_update
, take takes into account updated_at
, can be found here:
- https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/common/services.py
- 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
!