Build1 publisher2 min readPublished
Django's icontains steered Postgres around a trigram index by wrapping the column in UPPER()
Django's icontains made Postgres filter UPPER(product_name) while a team's pg_trgm index covered the raw column, so product searches ran for several seconds. Having the indexes proved nothing until the team read the generated SQL beside the query plan.
The Engineer · Build desk

What happened
- The slow searches surfaced while the team benchmarked PostgreSQL after migrating from MySQL, with trigram indexes already in place.
- New Relic located the affected operation, and CloudWatch and RDS metrics supplied the resource context around it.
- EXPLAIN showed the query bypassing the product-name trigram index while Postgres filtered the tenant's candidate products row by row.
- The team swapped icontains for contains to drop the UPPER() wrapper and set a case-insensitive collation on the product-name column.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision A team that finds this mismatch has to choose whether the lookup or the index moves; this one kept the index and took on a schema change to the column.
- constraint With contains in place, case-insensitive matching depends on the column's collation, so the speedup holds only once results are rechecked with representative search terms.
- exposure Other icontains lookups in a migrated codebase are suspects for the same UPPER() mismatch until someone has read their generated SQL and EXPLAIN output.
- cost Large tenants absorb most of the slowdown, because the fallback plan filters every candidate product they hold.
Django's call was `query.filter(product_name__icontains=value)` [3]. In the configuration the team investigated, it produced `UPPER(product_name::text) LIKE UPPER('%term%')` [4]. Their index was declared as `product_name gin_trgm_ops`, on the bare column, with a second trigram index on `itemid` [5]. So the query asked Postgres to search one expression while the index covered another [2]. "The product-name index covered the raw column, while the query searched its uppercase representation," the post's author wrote [6].
Row-by-row filtering costs more for every product a tenant holds [1]. The team recorded its comparison at about 4,908 requests per minute on the same db.r6i.large instance class [8]. The author is careful with that number, writing that similar throughput and instance size "still leave differences in query mix, configuration, and execution plans" [9]. For the several-second searches to reproduce on another system, two conditions have to hold. The predicate has to be one the trigram index cannot serve. And tenants have to hold enough products that one pass over them takes seconds [1].
The team kept its index and changed the query to fit it [10]. The post does not say whether an index on the uppercase expression was considered, and it does not give the Postgres or Django versions or the collation definition. I think keeping the index is the sound choice when case-insensitivity belongs to one column. The rule then lives once in the schema instead of at every call site. Correctness now rests on the collation the team set on product_name [11]. "Both parts mattered. A query becoming faster would not be sufficient if it stopped returning products that users expected to find," the author wrote [12]. The checks the post lists are case sensitivity, representative search terms and the results returned [13].
Claude appears in the post's title, and the author used it to analyse parts of the stack [14]. The diagnosis came from the generated SQL, the index definition and the query plan, read together [15]. On the assistant working from ORM code alone, the author wrote: "it has limited evidence" [15]. The follow-up prompt would get a good answer from any reviewer who has read a query plan: "Compare the expression being filtered with the expression being indexed" [16].
What to watch
- The PostgreSQL version and collation definition behind the fix, if the author publishes them, since they decide whether contains returns what icontains did.
- A post-fix EXPLAIN and latency comparison at the same roughly 4,908 requests per minute, showing the trigram index in the plan.
- Whether lookups against the itemid trigram index, also built on a raw column, show the same bypass.