Toolarity

Obsidian Bases contains(): One Function, Three Behaviours

Fix Obsidian Published September 23, 2026

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:

NoteauthortagsWhat it tests
P01murakamifictionall lowercase
P02MURAKAMIFICTIONall uppercase
P03MurakamiFictionnormal casing
P04HaRuKi MuRaKaMiFiCtIoNmixed
P05Haruki Murakaminonfictionsubstring trap
P06String Tags"fiction scifi" (a string)tags written as text, not a list
P07List Tags[fiction, scifi]the correct form
P08Empty List[]empty list
P09No Tags Field(missing)absent property
P10""fictionempty string
P11" Murakami"" fiction"leading space
P12村上春樹小说non-ASCII
P13Ursula K. Le Guinsci-fidots and hyphens
P14"42""2024"number written as a string
P15422024bare 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 P11
  • note.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 them
  • note.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 rows
  • file.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 → matches
  • tags.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 wantWriteBecause
Match a tagfile.hasTag("x")trims whitespace; agrees with the rest of Obsidian
Match part of a text propertynote.field.contains("x")substring, case-insensitive
Match a text property exactlynote.field == "x"but it’s case-sensitive and won’t trim
Match anything on a numbernot 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.