Obsidian Bases contains(): One Function, Three Behaviours
contains() is the function people reach for when == is too strict. It is also the
function that will quietly hand you the wrong rows, because it does three different
things depending on the type of the property you point it at, and only one of those
three throws an error when you get it wrong.
To pin this down I wrote fifteen notes, each one deliberately malformed in a different way, and ran eleven views against just those fifteen. Every result below comes from that run.
The probe notes
A normal vault can’t answer these questions — properties in a real vault are mostly consistent, which is exactly what makes case-sensitivity invisible. So:
| Note | author | tags | What it tests |
|---|---|---|---|
| P01 | murakami | fiction | all lowercase |
| P02 | MURAKAMI | FICTION | all uppercase |
| P03 | Murakami | Fiction | normal casing |
| P04 | HaRuKi MuRaKaMi | FiCtIoN | mixed |
| P05 | Haruki Murakami | nonfiction | substring trap |
| P06 | String Tags | "fiction scifi" (a string) | tags written as text, not a list |
| P07 | List Tags | [fiction, scifi] | the correct form |
| P08 | Empty List | [] | empty list |
| P09 | No Tags Field | (missing) | absent property |
| P10 | "" | fiction | empty string |
| P11 | " Murakami" | " fiction" | leading space |
| P12 | 村上春樹 | 小说 | non-ASCII |
| P13 | Ursula K. Le Guin | sci-fi | dots and hyphens |
| P14 | "42" | "2024" | number written as a string |
| P15 | 42 | 2024 | bare number |
The control view — all fifteen, no property filter — returned 15. Without that number, every zero-result view below would be ambiguous.
It does not care about case
Two views, identical except for the search term:
note.author.contains("Murakami")→ 6 rows: P01 P02 P03 P04 P05 P11note.author.contains("murakami")→ the same 6 rows
Same set. Not a similar count — the same notes. So contains() is case-insensitive,
and a third view wrapping it in .lower() returned the same six again.
If you came from Dataview and habitually write lower(x).contains(lower(y)), you
can stop. It works, it just isn’t doing anything.
On text, it matches substrings
note.author.contains("uraka") — a fragment from the middle of a word, matching
nothing at a word boundary — returned the same six notes. So on a text property this
is ordinary substring matching.
That is probably what you expected. Now the part that isn’t.
On a list, it matches whole elements
Here is the question everyone asks: will searching tags for fiction also match
nonfiction?
No. It will not.
note.tags.contains("fiction")→ 6 rows, and P05 (nonfiction) is not among themnote.tags.contains("nonfiction")→ 1 row, P05 alone
On a list property, contains() asks “is any element of this list equal to the
string I gave you” — case-insensitively, but equal, not containing. sci does not
match sci-fi either; that view returned only the note I searched for exactly.
This catches people from both directions. Half expect the substring behaviour and
are surprised it’s safe. The other half assume it’s safe everywhere and then write
note.author.contains("fiction") on a text property, where nonfiction does
match, because there it really is a substring search.
Same function name. Same syntax. Opposite behaviour. The only difference is whether the property happens to be a list.
On a number, it throws
note.author.contains("42") returned exactly one row — P14, whose author is the
string "42". P15, whose author is the bare number 42, did not match.
It also produced an error toast.
Failed to evaluate a filter: Cannot find function “contains” on type Number
The same toast appeared on every contains() view in this test vault, not just this
one, because P15 is in the folder they all search. Calling .lower() on the same
property gave the equivalent message: Cannot find function “lower” on type Number.
Note what happened there: the view returned a correct-looking result and an error, at the same time. The one row it found was genuinely right. The error was about a different note entirely.
Which leads to the worst thing I found.
One badly-typed note breaks every view that touches that property
P15 has author: 42 — a bare number where every other note has text. That single
note causes an error toast on every view that calls note.author.contains(...),
including views whose results are completely correct and have nothing to do with P15.
Six of my eleven views used note.author.contains(...). All six threw. The five that
used tags, ==, or hasTag() did not.
So in a real vault, the symptom is: you open a base, you get an error about a function and a type, your filter looks fine, and the note actually causing it is one you have never thought about and which may not even appear in the results.
Before debugging the filter, go and check whether one note has the wrong type in that property.
(How I confirmed this rather than assuming it: screenshots of views that threw were
consistently ~30 KB, versus ~13 KB for views that didn’t, because of the extra toast.
Sorting the screenshots by file size produced a group that matched the set of views
using note.author.contains exactly.)
hasTag() and tags.contains() are not the same function
This one cost me the most time.
note.tags.contains("fiction")→ 6 rowsfile.hasTag("fiction")→ 7 rows
The extra note is P11, whose frontmatter reads tags: [" fiction"] — with a leading
space.
hasTag()reads Obsidian’s parsed tag cache, which has already been trimmed → matchestags.contains()reads the raw frontmatter value, space intact → misses
Obsidian’s own interface displays P11’s tag in red with a strikethrough, marking it
invalid — and hasTag() matches it anyway.
For tags, use file.hasTag(). It is more forgiving of the kind of whitespace
that gets into frontmatter through copy-paste, and it agrees with how the rest of
Obsidian understands your tags.
== is case-sensitive and does not trim
note.author == "Murakami" matched one note: P03.
Not P01 (murakami), not P02 (MURAKAMI) — that’s expected. But also not P11,
whose author is " Murakami" with a leading space.
Here is why that’s dangerous rather than merely strict: in the results table, P03
and P11 look identical. Both render as Murakami. The leading space is invisible.
You will look at the table, see two rows that plainly match, write == "Murakami",
get one row back, and have no visual explanation for the missing one.
Two ways to break tags before contains() ever runs
Writing tags as a space-separated string. P06 has tags: "fiction scifi". Both
tags.contains("fiction") and hasTag("fiction") miss it. Obsidian reads the whole
string as a single tag named fiction scifi and marks it invalid. This is a natural
thing to write coming from Dataview. Tags must be a YAML list.
Nothing else breaks. A missing property (P09), an empty list (P08), and an empty string (P10) all simply fail to match. No errors. Only the wrong type throws — absent is fine, empty is fine, number is not.
What to actually write
| You want | Write | Because |
|---|---|---|
| Match a tag | file.hasTag("x") | trims whitespace; agrees with the rest of Obsidian |
| Match part of a text property | note.field.contains("x") | substring, case-insensitive |
| Match a text property exactly | note.field == "x" | but it’s case-sensitive and won’t trim |
| Match anything on a number | not contains() | use ==, >, < — contains() throws |
And one habit worth forming: when a base throws a type error, the note causing it is usually not in the result set. Search your frontmatter for the odd one out before you touch the filter.
Tested on the version in the box at the top of this page. Verify against your own install before relying on the specifics.