PostgREST .or() Breaks Silently on a Comma
This article may contain affiliate links. Its content is not affected by advertising.
In short
Interpolating a raw term into a supabase-js .or() filter breaks on a comma, and since it returns {data:null} instead of throwing, that category silently drops out.
The short version
Interpolating a raw search term into a PostgREST .or() filter via a template literal breaks the filter grammar on a reserved character like a comma. On top of that, supabase-js doesn’t throw on a transport or PostgREST failure — it represents it as a {data: null, error} return value — so the moment the caller reads .data ?? [], a broken filter that turns data into null gets treated as an empty array with no exception and no log. Global search’s contacts query broke exactly this way. The fix was to split name and email into separate parameterized .ilike() calls and merge the results in JS.
The state it could become
The subject is global search in kimiteras-portal — the source behind both search and the command palette. It queries companies, contacts, projects, contracts, schools, tasks, and documents in one Promise.all round trip. Contacts alone need to match on both name and email, and before the fix it was written like this:
supabase
.from("contacts")
.select("id, name, email, company_id, school_id")
.or(`name.ilike.${like},email.ilike.${like}`)
.limit(5),
like is the search term formatted into %...%, and wildcard characters (%, _, \) were escaped. What wasn’t escaped was the delimiter .or() itself uses: the comma. The result was handled the same way as every other category:
for (const c of contacts.data ?? [])
hits.push({
type: "Contact",
label: c.email ? `${c.name} (${c.email})` : c.name,
...
});
The first argument to .or() is a PostgREST-specific grammar string, name.ilike.<value>,email.ilike.<value>, where a comma is interpreted as a condition separator. If the search term itself contains a comma — someone typing a “Last, First” style name, or pasting a name straight off a business card — the generated string splits into extra conditions for every comma in it, and PostgREST can no longer parse it as a valid filter.
Why
What made this worse was how the failure surfaced. supabase-js query methods are designed not to reject their promise — a transport error and a PostgREST error response are both represented as the same {data, error} shape. This code read only contacts.data ?? [] and didn’t even destructure error. When the broken filter made PostgREST return an error, data simply became null, no exception was thrown, and ?? [] quietly treated it as a valid “zero contacts matched” result. Every other category — companies, projects, contracts — still matched fine, so the UI just looked like “a slightly thin result set,” with no way to notice that the contacts category alone was vanishing for specific search terms.
Fixing it
Name and email were split into separate parameterized .ilike() queries, with the results merged and deduplicated in JS.
// Query name and email separately with parameterized .ilike and merge in JS. Interpolating
// raw input into .or() lets a reserved PostgREST character like a comma break the filter
// grammar, silently dropping the contacts category (e.g. q="Last, First"). .ilike binds its
// value as a parameter, so reserved characters are safe.
supabase
.from("contacts")
.select("id, name, email, company_id, school_id")
.ilike("name", like)
.limit(5),
supabase
.from("contacts")
.select("id, name, email, company_id, school_id")
.ilike("email", like)
.limit(5),
.ilike() binds its second argument as a parameter, so a value containing a comma or . — reserved PostgREST characters — doesn’t break the filter grammar itself. Because a name match and an email match can return the same contact twice, the merge side uses a Set to push each ID into hits only once.
const seenContact = new Set<string>();
for (const c of [...(contactsByName.data ?? []), ...(contactsByEmail.data ?? [])]) {
if (seenContact.has(c.id)) continue; // dedupe a contact matched on both name and email
seenContact.add(c.id);
hits.push({ ... });
}
To prevent a repeat, a static-guard test now bans raw interpolation into .or() outright:
it("does not interpolate the search query raw into PostgREST .or() filter grammar (injection prevention)", () => {
expect(src).not.toContain(".or(`");
expect(src).not.toContain("ilike.${");
});
Because the test inspects the source string directly, CI fails the moment anyone reintroduces a template literal into .or() for the same reason, no matter why.
The lesson
?? [] is a common idiom for “a safe default when there’s no value,” but combined with a client like supabase-js that represents failure with the same shape as success, it erases the distinction between “zero rows matched” and “the query itself failed.” Here the trigger was a different bug — raw interpolation into .or() — but the underlying danger is shared by every call site that reads data without checking error. In a design that bundles several categories into one request via Promise.all, the category that fails is the one that goes quietly missing, and the fact that every other category returns normally is exactly what makes the gap hard to notice.
Frequently asked questions
Q1Why does it fail silently instead of throwing an error?
supabase-js doesn't throw on failure — it returns {data, error}. If the caller only reads data and falls back with .data ?? [], a broken filter that turns data into null reads as zero hits, so that category silently goes to zero.
Q2What kind of search term breaks it?
PostgREST's .or() filter uses commas to separate conditions, so a search term that itself contains a comma (e.g. a "Last, First" style name) breaks the grammar when interpolated raw. Other reserved characters like . or ( can break it the same way.
Q3How was the bug fixed?
Name and email are now queried separately with their own parameterized .ilike() calls, and the results are merged and deduplicated by ID in JS. .ilike() binds its value as a parameter, so it doesn't break even on search terms containing reserved characters.
Q4Is there a record of this actually dropping results in production?
The commit backing this only records what was fixed, not a log of which search terms were affected in production or for how long. The code structure shows it could happen whenever someone searched a contact name or email containing a comma.
Environment verified
- @supabase/supabase-js ^2.106.2 / @supabase/ssr ^0.10.3
- Found in review on 2026-07-13 and fixed the same day
What this article is based on
- TypeScript file lines 29-33commit 4873430
- TypeScript file lines 58-68commit 4873430
- TypeScript file lines 29-41commit 8931860
- TypeScript file lines 100-115commit 8931860
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.