Gmail’s is:unread can return threads you already read
In one real mailbox, up to 89% of the threads Gmail returned for is:unread held no unread message at all. The same query, in the same minute, asked through a different endpoint, returned none of them. The behaviour was reported to Google in 2018, closed as not reproducible, and is still there.
What happens
Gmail’s search operators for read state are applied by the API to an index. For users.threads.list, the value in that index is a thread-level read state, and it can lag behind the mailbox. A thread whose every message has been read can still come back for is:unread, weeks after the fact.
The operator is not being dropped — that was the first thing checked. Removing is:unread from the same query returns 800+ threads instead of a few dozen, so the filter is running. It is running against a copy of the truth that has not caught up.
The measurement
Method: ask the index, then ask the mailbox about every answer. Each thread the index returned was fetched again and its live labels read; a thread counts as stale when it came back for is:unread and none of its messages actually carries the UNREAD label. Read-only, metadata only — no subject, sender or body was fetched. The mailbox is a private one of roughly 70,000 messages, in use for years.
Query through threads.list | Threads | Actually unread | Stale |
category:updates is:unread | 131 | 17 | 87% |
category:updates is:unread -in:inbox | 128 | 14 | 89% |
is:unread -in:inbox | 235 | 99 | 58% |
The third row matters more than the two above it. It is the plainest query of the three — one operator, no category, no negated container — and it shows the effect just as well. Its share is the lowest, and in absolute terms it is the largest: 136 threads that were not unread. Whatever this is, it does not need an exotic combination of operators.
It is the thread index specifically
The next day, the same query was asked through both endpoints minutes apart:
| Endpoint | Hits | Stale |
users.threads.list | 132 | 114 |
users.messages.list | 19 | 0 |
So the honest statement is not “Gmail search is unreliable”. It is narrower and more useful: the thread-level view of read state lags, the per-message view does not. Code that resolves a read-state query through threads.list inherits the lag; the same code asking messages.list does not.
What it is not
A second mailbox, measured identically on the same day, drifted not at all. That is why the claim here is about a mailbox, not about Gmail as a whole — and it is deliberately not offered as a counter-example to any cause: the two differ by roughly three orders of magnitude in volume, and in the second, no thread was ever archived while still unread, which is the only shape this can show up on. It is not a mailbox that refutes the effect. It is a mailbox without the conditions for it.
The operative consequence is not the percentage. It is that nothing in an API response tells you which kind of mailbox you are talking to.
What it means if you query Gmail
Two responses are available, and they cost differently. Re-verify: fetch each hit and check the predicates against its live labels — free where the fetch happens anyway. Or declare what was taken on trust:for bulk paths sized in thousands of messages, one fetch per hit is a different order of cost, so the honest move is to report which conditions rest on the index’s word rather than to imply they were checked. A dry run does not close that gap — it re-reads the same index, so it tells you how large the set is, never whether it is right.
What neither response allows is treating the index’s answer as the mailbox’s state. Asked to “archive the unread promotional mail that already skips my inbox”, a tool reaches for category:updates is:unread -in:inbox — the second row above, where 89% of what came back had already been read. Acting on that archives mail nobody meant to touch.
Reported twice, eight years apart
The finding was filed with Google on 2 September 2026 as issue 555806033 against the Gmail API component.
Searching that component before filing turned up issue 78095953 from April 2018: a label:unread query for which threads.list returns a message whose labels do not include it, confirmed by fetching the message, and not reproduced by the web interface. That is the same behaviour, eight years earlier. It was closed Can’t Repro, without a single comment.
The interesting part is the why. The 2018 report named a suspected trigger — an assist feature of the time that no longer exists. That is the plausible reason it could not be reproduced later: the suspected cause aged out while the behaviour did not, and the report was closed on the cause. The new report therefore names no cause at all. It supplies only what the old one lacked: counts from a named date, a control query showing the operator is applied, a mailbox that does not exhibit it, and the endpoint comparison.
What is not claimed here: that Google ignored it. What is documented is that two reports were closed as not reproducible after naming a suspected cause — not what happened internally.
Reproduce it yourself
The full report — method, all three queries, both controls, the raw figures with their provenance, and the readings the numbers rule out — is published with the source it was found in: gmail-thread-read-state-drift.md. It includes a script that measures your own mailbox read-only, and a second that demonstrates the behaviour against a fake API with no Google account involved.
The finding came out of building mailwarden, our open-source Gmail MCP server, which re-verifies search results for exactly this reason.
