Django annotate with ExtractMonth and ExtractYear doesnt extract year
I have this model:
class KeyAccessLog(models.Model):
key = models.ForeignKey(
Key, related_name="access_logs", on_delete=models.CASCADE
)
path = models.CharField(max_length=255)
method = models.CharField(max_length=10)
ip_address = models.GenericIPAddressField()
created = models.DateTimeField(auto_add_now=True)
class Meta:
verbose_name = "Key Access Log"
verbose_name_plural = "Key Access Logs"
ordering = ("-pk",)
indexes = [
models.Index(
models.Index(
"key",
ExtractMonth("created"),
ExtractYear("created"),
name="key_month_year_idx",
),
),
]
def __str__(self):
return f"{self.key} - {self.path} - {self.method}"
My point is to use the declared index when filtering by key
, month
, and year
but the query that is generated from ORM is not extracting as it does for the month.
dt_now = timezone.now()
qs = key.access_logs.filter(created__month=dt_now.month, created__year=dt_now.year)
print(qs.query)
gives me:
SELECT
"public_keyaccesslog"."id",
"public_keyaccesslog"."key_id",
"public_keyaccesslog"."path",
"public_keyaccesslog"."method",
"public_keyaccesslog"."ip_address",
"public_keyaccesslog"."created"
FROM
"public_keyaccesslog"
WHERE
(
"public_keyaccesslog"."key_id" = 1
AND EXTRACT(
MONTH
FROM
"public_keyaccesslog"."created" AT TIME ZONE 'UTC'
) = 5
AND "public_keyaccesslog"."created" BETWEEN '2025-01-01 00:00:00+00:00' AND '2025-12-31 23:59:59.999999+00:00'
)
ORDER BY
"public_keyaccesslog"."id" DESC
Now I tried explicitly to annotate extracted month and year:
dt_now = timezone.now()
qs = key.access_logs.annotate(
month=ExtractMonth("created"),
year=ExtractYear("created")
).filter(month=dt_now.month, year=dt_now.year)
print(qs.query)
this gives me almost the same SQL:
SELECT
"public_keyaccesslog"."id",
"public_keyaccesslog"."key_id",
"public_keyaccesslog"."path",
"public_keyaccesslog"."method",
"public_keyaccesslog"."ip_address",
"public_keyaccesslog"."created",
EXTRACT(
MONTH
FROM
"public_keyaccesslog"."created" AT TIME ZONE 'UTC'
) AS "month",
EXTRACT(
YEAR
FROM
"public_keyaccesslog"."created" AT TIME ZONE 'UTC'
) AS "year"
FROM
"public_keyaccesslog"
WHERE
(
"public_keyaccesslog"."key_id" = 1
AND EXTRACT(
MONTH
FROM
"public_keyaccesslog"."created" AT TIME ZONE 'UTC'
) = 5
AND "public_keyaccesslog"."created" BETWEEN '2025-01-01 00:00:00+00:00' AND '2025-12-31 23:59:59.999999+00:00'
)
ORDER BY
"public_keyaccesslog"."id" DESC
The year is never filtered by extracted value like the month, and then my index doesn't make any sense.
I would expect it to be:
SELECT
"public_keyaccesslog"."id",
"public_keyaccesslog"."key_id",
"public_keyaccesslog"."path",
"public_keyaccesslog"."method",
"public_keyaccesslog"."ip_address",
"public_keyaccesslog"."created",
EXTRACT(
MONTH
FROM
"public_keyaccesslog"."created" AT TIME ZONE 'UTC'
) AS "month",
EXTRACT(
YEAR
FROM
"public_keyaccesslog"."created" AT TIME ZONE 'UTC'
) AS "year"
FROM
"public_keyaccesslog"
WHERE
(
"public_keyaccesslog"."key_id" = 1
AND EXTRACT(
MONTH
FROM
"public_keyaccesslog"."created" AT TIME ZONE 'UTC'
) = 5
AND EXTRACT(
YEAR
FROM
"public_keyaccesslog"."created" AT TIME ZONE 'UTC'
) = 2025
)
ORDER BY
"public_keyaccesslog"."id" DESC
Any solution for this?
The "culprit" if you want is some custom logic Django added for this. Indeed:
class YearExact(YearLookup, Exact): def get_direct_rhs_sql(self, connection, rhs): return "BETWEEN %s AND %s" def get_bound_params(self, start, finish): return (start, finish)
Which is registered as the __exact
lookup for an ExtractYear
object (not to be confused with YearExact
thus).
It probably also does not make much sense to add a separate index for year: just adding an index on created
will have (almost) the same performance, since it performs logarithmic search, and using an index on ExtractYear
can result in extra bookkeeping, since a lot for records will map on 2025 probably.
If you really want to get rid of this, you could just register your own ExtractYear
, like:
from django.db.models.functions.datetime import ExtractYear
from django.db.models.lookups import Exact
class MyExtractYear(ExtractYear):
pass
MyExtractYear.register_lookup(Exact)
and then thus use MyExtractYear
instead. But it is probably not worth the effort, and could even result in less efficient querying.