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.

Вернуться на верх