Fuzzy String Matching in Django and PostgreSQL

Gerald Carlton and I will be presenting on fuzzy name search at DjangoCon US 2026 on Monday, August 24, and this is the companion blog post. Searching for a person by name is harder than it looks: names might be typed differently by different operators or change over a person’s lifetime; for example, Smith could be entered as Smyth, Smythe, or Smidt, and Weiss as Weiß. Although names are particularly susceptible to misspelling, these strategies apply to all fuzzy string matching.

In this post, we’ll look at the small set of building blocks you need to do fuzzy string matching in PostgreSQL through the Django ORM: the utility class for each of Soundex, Daitch-Mokotoff, and Levenshtein edit distance, Django’s built-in trigram classes, and the index to add for each.

Prerequisites

Enable the extensions with a one-off migration:

from django.contrib.postgres.operations import CreateExtension
from django.db import migrations


class Migration(migrations.Migration):
    operations = [
        # soundex(), daitch_mokotoff(), levenshtein_less_equal()
        CreateExtension(name="fuzzystrmatch"),
        # similarity() and the <-> trigram distance operator
        CreateExtension(name="pg_trgm"),
    ]

Note: these must exist before you create any index on the phonetic expressions, so run this migration first.

The utility classes

Three of the four functions have no Django built-in, so you wrap them as tiny custom Func expressions. Put this in your_app/expressions.py and import from it:

from django.contrib.postgres.fields import ArrayField
from django.db.models.expressions import Func
from django.db.models.fields import CharField, IntegerField, TextField


class Soundex(Func):
    """PostgreSQL soundex(name): a 4-character code. Names that sound alike
    share a code, so soundex('Smith') = soundex('Smyth') = 'S530'.
    """

    function = "soundex"
    output_field = CharField()


class DaitchMokotoff(Func):
    """PostgreSQL daitch_mokotoff(name): a text[] of phonetic codes. Better
    than Soundex for Slavic/Ashkenazi names and some accents. Match names when
    their code arrays overlap (the && operator).
    """

    function = "daitch_mokotoff"
    output_field = ArrayField(TextField())


class LevenshteinLessEqual(Func):
    """PostgreSQL levenshtein_less_equal(a, b, threshold): the edit distance
    between two strings, bailing out early past the threshold (which keeps it
    cheap on big tables). If the distance exceeds the threshold, the result
    is some value greater than it, so `__lte=threshold` keeps exactly the
    names within the threshold.
    """

    function = "levenshtein_less_equal"
    output_field = IntegerField()

Trigrams are different: Django already ships them in django.contrib.postgres.search:

from django.contrib.postgres.search import TrigramSimilarity, TrigramDistance

TrigramSimilarity wraps similarity() and TrigramDistance wraps the <-> nearest-neighbor operator.

A simple example of each

Assume you have a Person model with first_name and last_name fields. Here are examples of each function.

Use Soundex to group names by how they sound in English:

from django.db.models import F, Value
from .expressions import Soundex

Person.objects.annotate(
    last_sdx=Soundex(F("last_name"))
).filter(last_sdx=Soundex(Value("Smyth")))
# matches Smith, Smyth, and Smythe -- all code S530

Use Daitch-Mokotoff to match on overlapping code arrays, using Django’s built-in __overlap lookup. This function has better support for German/Slavic European pronunciation:

from django.db.models import F, Value
from .expressions import DaitchMokotoff

Person.objects.annotate(
    last_dm=DaitchMokotoff(F("last_name"))
).filter(last_dm__overlap=DaitchMokotoff(Value("Weiss")))
# matches both Weiss and Weiß, which share code 740000

Levenshtein keeps only names within a certain number of “edits” (single-character changes, additions or removals) of the query. Unlike the phonetic functions above, levenshtein_less_equal is case-sensitive, so the example normalizes both sides with Upper(). It’s expensive since an index can’t be pre-computed, so use it as a precision filter on top of the broader phonetic matches:

from django.db.models import F, Value
from django.db.models.functions import Upper
from .expressions import LevenshteinLessEqual

Person.objects.annotate(
    # levenshtein_less_equal() IS case-sensitive, so ensure the passed value is
    # in a matching case, or the edit distance will include case-mismatches.
    last_dist=LevenshteinLessEqual(Upper(F("last_name")), Value("SMYTH"), Value(2))
# Although levenshtein_less_equal() will avoid calculating the actual distance if
# greater than 2, we still need to filter out such cases from the queryset.
).filter(last_dist__lte=2)
# keeps Smith (1), Smyth (0), Smythe (1); drops anything farther

Trigrams rank by closeness with the built-in TrigramDistance and take the top N. This is a ranking, not a threshold.

from django.db.models import F, Value
from django.contrib.postgres.search import TrigramDistance

Person.objects.annotate(
    # pg_trgm normalizes case, so we don't have to:
    last_dist=TrigramDistance(F("last_name"), Value("Smith"))
).order_by("last_dist")[:20]
# the 20 closest last names to "Smith"

Combine them however your data needs: the phonetic functions (Soundex, Daitch-Mokotoff) give you broad recall, Levenshtein tightens the results, and trigrams give you a “closest match” ranking.

Which index to add

None of these is fast without the right index. Add one index per search type to your model’s Meta.indexes:

SearchPostgreSQL expressionIndexCase
Soundex equalitysoundex(col)B-treeinsensitive
Daitch-Mokotoff overlapdaitch_mokotoff(col)GINinsensitive
Trigram KNN / similaritygist_trgm_ops on the columnGiSTinsensitive
Prefixupper(col) + text_pattern_opsB-treeinsensitive
Levenshteinnone (apply on top of an indexed pre-filter)sensitive

soundex(), daitch_mokotoff(), and the trigram operators ignore letter case on their own. LIKE (prefix) and levenshtein_less_equal() are case-sensitive, so the examples wrap those columns in upper() to match case-insensitively. The underlying prefix search itself is still case-sensitive, so upper() is required on both the index and the query value.

from django.contrib.postgres.indexes import GinIndex, GistIndex, OpClass
from django.db import models
from django.db.models import F
from django.db.models.functions import Upper
from .expressions import DaitchMokotoff, Soundex

class Meta:
    indexes = [
        models.Index(Soundex(F("last_name")), name="last_name_soundex"),
        GinIndex(DaitchMokotoff(F("last_name")), name="last_name_dm"),
        GistIndex(fields=["last_name"], opclasses=["gist_trgm_ops"], name="last_name_trgm"),
        models.Index(
            # Order fields based on which one users will typically type
            # first when searching-as-they-type:
            OpClass(Upper(F("last_name")), name="text_pattern_ops"),
            OpClass(Upper(F("first_name")), name="text_pattern_ops"),
            name="name_prefix",
        ),
    ]

Note: the Soundex, Daitch-Mokotoff, and trigram indexes above are on last_name only; if you also search by first name, add a separate index on first_name for each of them. The prefix index spans both names because it can efficiently search on both names using the same index.

Note: a functional index only helps if the query uses the identical expression. If the index is on soundex(last_name) but your query computes soundex(upper(last_name)), PostgreSQL won’t use it and you’ll silently get a full scan.

Wrapping up

In summary, we’ve walked through three small Func wrappers for Soundex, Daitch-Mokotoff, and Levenshtein, Django’s built-in TrigramSimilarity/TrigramDistance for trigrams, and one functional index per search type.

Good luck building your search! If you’d like to see these techniques in a search-as-you-type interface at scale, join us at the DjangoCon US 2026 talk; the talk’s notebook and sample app will be available before the talk to follow along.