Case-Insensitive Fields in Django Models

If you are trying to figure out how to create a case-insensitive field for your Django models, there are several ways to go about it. If you don’t care about preserving the actual mixed-case version, you can simply convert your strings to lowercase before they get saved to the model. If you are using PostgreSQL and don’t mind a database-specific solution, you can use CITextField or similar. Otherwise, read on.

Some solutions online involve adding or customizing manager or queryset functions. While this may work okay at a basic level, it isn’t a complete solution, and doesn’t actually prevent you from inserting a duplicate case-insensitive value.

I have discovered a solution that is clean, enforces case-insensitive uniqueness, and works for all Django lookups that have case-insensitive versions.

Creating a Mixin

The Django Field class has a get_lookup() method that takes in a lookup_name argument. This is very easy to override and do a quick conversion on the lookup name before passing it to the superclass. In fact, the Django docs have an example of how to customize the behavior of this method.

We can take advantage of this by creating a Field mixin that overrides this method and does a quick conversion on lookups to their case-insensitive versions, if any exist.

class CaseInsensitiveFieldMixin:
    """
    Field mixin that uses case-insensitive lookup alternatives if they exist.
    """

    LOOKUP_CONVERSIONS = {
        'exact': 'iexact',
        'contains': 'icontains',
        'startswith': 'istartswith',
        'endswith': 'iendswith',
        'regex': 'iregex',
    }

    def get_lookup(self, lookup_name):
        converted = self.LOOKUP_CONVERSIONS.get(lookup_name, lookup_name)
        return super().get_lookup(converted)

The mixin converts the lookups to their case-insensitive alternatives, if any exist (e.g. “exact” -> “iexact”). It is worth mentioning that lookups that do not have case-insensitive versions (e.g. “in”) will not be case-insensitive.

Usage

Here is a test model with case-insensitive name and email fields we will use for demonstration:

from django.db import models


class CICharField(CaseInsensitiveFieldMixin, models.CharField):
    pass


class CIEmailField(CaseInsensitiveFieldMixin, models.EmailField):
    pass


class TestModel(models.Model):
    name = CICharField(unique=True, max_length=20)
    email = CIEmailField(unique=True)

Demonstration

You can now perform lookups on your field with exact, contains, startswith, endswith, and regex as you would on any other field, and they will automatically work case-insensitively.

>>> TestModel.objects.create(name='test', email='test@test.com')
<TestModel: TestModel object (4)>
>>> TestModel.objects.get(name='TeSt', email='tEst@teST.Com')
<TestModel: TestModel object (4)>
>>> TestModel.objects.filter(name='TEST', email='TEST@TEST.COM')
<QuerySet [<TestModel: TestModel object (4)>]>
>>> TestModel.objects.filter(name__contains='Te', email__contains='tEst')
<QuerySet [<TestModel: TestModel object (4)>]>
>>> TestModel.objects.filter(name__startswith='Te', email__startswith='tEst')
<QuerySet [<TestModel: TestModel object (4)>]>
>>> TestModel.objects.filter(name__endswith='ST', email__endswith='.CoM')
<QuerySet [<TestModel: TestModel object (4)>]>
>>> TestModel.objects.filter(name__regex='TeSt', email__regex='tEst@teST.Com')
<QuerySet [<TestModel: TestModel object (4)>]>

Creating an Index

What we have so far works great for non-unique fields, but we are still not enforcing case-insensitive uniqueness at the database level. To resolve this, we need to create a unique database index on the uppercase value of the field. We need to do this with raw SQL in a migration.

Note: You will need to pip install sqlparse to run raw SQL in your migrations.

from __future__ import unicode_literals

from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
        ('demonstration', '0001_initial'),
    ]

    operations = [
        migrations.RunSQL(
            sql=r'CREATE UNIQUE INDEX name_upper_idx ON demonstration_testmodel(UPPER(name));',
            reverse_sql=r'DROP INDEX name_upper_idx;'
        ),
        migrations.RunSQL(
            sql=r'CREATE UNIQUE INDEX email_upper_idx ON demonstration_testmodel(UPPER(email));',
            reverse_sql=r'DROP INDEX email_upper_idx;'
        ),
    ]

Here, we have created unique indices for both fields using upper() so that it is case-insensitive.

Demonstration

Now, we can be assured that our fields are case-insensitively unique.

>>> TestModel.objects.create(name='TeSt', email='test2@test.com')
Traceback (most recent call last):
  File "/home/levi/Envs/blog-django-case-insensitive/lib/python3.6/site-packages/django/db/backends/utils.py", line 85, in _execute
    return self.cursor.execute(sql, params)
  File "/home/levi/Envs/blog-django-case-insensitive/lib/python3.6/site-packages/django/db/backends/sqlite3/base.py", line 296, in execute
    return Database.Cursor.execute(self, query, params)
sqlite3.IntegrityError: UNIQUE constraint failed: index 'name_upper_idx'

>>> TestModel.objects.create(name='test2', email='TeSt@TeSt.cOm')
Traceback (most recent call last):
  File "/home/levi/Envs/blog-django-case-insensitive/lib/python3.6/site-packages/django/db/backends/utils.py", line 85, in _execute
    return self.cursor.execute(sql, params)
  File "/home/levi/Envs/blog-django-case-insensitive/lib/python3.6/site-packages/django/db/backends/sqlite3/base.py", line 296, in execute
    return Database.Cursor.execute(self, query, params)
sqlite3.IntegrityError: UNIQUE constraint failed: index 'email_upper_idx'

Conclusion

There are several ways to go about creating a case-insensitive field in Django, but I believe this is the most complete and database-agnostic solution that I have encountered.

For the full example code, including unit tests, check out the repository on GitHub.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.