Moodle Returns Questions You Excluded by Tag, in the Question Bank and in Random Quizzes
On Moodle 4.3 to 4.5 the question bank tag filter's Match None option returns questions that carry the tag you excluded, and never returns untagged ones. Here is how to check whether you are affected, and what to do about it.
The Moodle question bank filters by tag, and the Tag row carries a Match dropdown with None, Any and All. Match None is the one to reach for when a pool has to exclude something: every question in the bank except those tagged retired, say, or a random question that must never draw from a set that has been withdrawn.
On Moodle 4.3, 4.4 and 4.5, that is not what it returns. Depending on how the bank is tagged, the result is either empty or a list that still contains the very questions the filter was written to keep out. It behaves this way on the question bank screen and in random question selection alike, because both run the same code.
The second case is the one that matters, because nothing on screen indicates that anything went wrong.
Check whether you are affected in 30 seconds
Pick a question that carries two tags, for example retired and chapter1. Filter the question bank with Match set to None and retired selected.
- If that question appears in the results, you are on an affected version.
- If it does not appear, and untagged questions do, your filter is working correctly.
If you have not used Match None, you are not affected. Only the None option is involved: Any and All behave the same on 4.3 as they do on 5.2.
What the filter actually returns
On the affected versions, Match None does not mean “questions without the selected tag”. It means “questions that carry at least one tag that is not the selected one”. Those are different sets, and two consequences follow.
Questions you excluded come back anyway. A question tagged both retired and chapter1 satisfies that rule, because chapter1 is not retired. It appears in your “none” result despite being tagged retired.
Untagged questions never appear. A question with no tags at all carries no tag that is not the selected one, so it is always excluded, even though it plainly has none of the tags you picked.

Moodle’s own test fixture shows the difference. Six questions:
| Question | Tags |
|---|---|
| q1 | math |
| q2 | math, algebra |
| q3 | math, algebra, advanced |
| q4 | geometry |
| q5 | math, geometry |
| q6 | (none) |
Filtering with Match None:
| Filter | 4.3 to 4.5 returns | 5.0 and later returns |
|---|---|---|
None: math |
q2, q3, q4, q5 | q4, q6 |
None: math + geometry |
q2, q3 | q6 |
None: algebra |
q1, q2, q3, q4, q5 | q1, q4, q5, q6 |
In the first row, three of the four questions the older versions return are tagged math. That is the exact set the filter was asked to exclude.
This is also why the symptom looks different from site to site. If every question in your bank carries exactly one tag and you filter on it, everything fails the rule and you get an empty list, which at least looks like a problem. If your questions carry several tags, you get a full-looking result that is quietly wrong.
Which versions are fixed
The fix is MDL-84966. It landed in 5.0, 5.1 and 5.2, and was not backported to 4.5 or earlier.
| Version | Match None | General support ends | Security support ends |
|---|---|---|---|
| 4.5 LTS | Broken | 2025-10-06 | 2027-10-04 |
| 5.0 | Fixed | 2026-04-20 (passed) | 2026-10-05 |
| 5.1 | Fixed | 2026-10-05 | 2027-04-19 |
| 5.2 | Fixed | 2027-04-19 | 2027-10-04 |
If you are already on 5.0 or later, Match None does what the label says and there is nothing to do.
If you are upgrading in order to resolve this, go to the latest Moodle release your site can take, rather than to the earliest version that carries the fix. Check its PHP requirement and its supported upgrade path first: Moodle does not support an unlimited jump, so a site on an older 4.x may need an intermediate step.
Note that 4.5 is the current LTS, with security support running to October 2027. Many sites will legitimately stay on it for another year or more. Unless they apply a tested local backport of the fix, those sites keep this behavior for as long as they stay on 4.5, which is what makes the workaround below worth setting up.
The workaround: tag what you want, not what you don’t
If you cannot upgrade, stop expressing the rule as an exclusion. Add a second tag to the questions you do want, for example active, and filter with Match Any on active.
Be clear about what this changes. You are swapping the rule “everything except retired” for the rule “everything tagged active“. Those are not the same rule, and they only stay equivalent if you maintain the active tag deliberately. It is the safest workaround that needs no code, for three reasons.
It works before and after the fix. Any and All are not affected by this bug, so the same configuration behaves the same on 4.3 as on 5.2 and survives the upgrade without being unpicked.
It fails visibly rather than silently. This is the real argument. Both rules can go wrong, but they go wrong in different ways. If somebody forgets to tag a new question active, it is missing from a pool that is supposed to contain it, and that tends to get noticed. The broken exclusion rule quietly hands out questions you meant to withhold, and nothing reports it.
It behaves the same in quizzes. Random question criteria run through the same filtering code as the bank screen, so a positive tag is predictable in both places.
The maintenance cost is real, so plan for it. A new question that belongs in the pool has to be tagged, and a question that carries both retired and active lands back in the pool you were trying to protect. Both are worth a periodic check.
On the other approaches: moving questions into a separate category is a perfectly reasonable design when the pools are genuinely disjoint, and only becomes awkward when a question needs to belong to more than one. Filtering with Match Any across every other tag works until somebody adds a tag, at which point the pool changes without anyone touching the quiz. Removing the retired tag destroys information you presumably wanted to keep.
If you are staying on 4.5 and lean on exclusion filters heavily, a tested local backport of the MDL-84966 patch is the other option. It restores the intended behavior rather than working around it, at the cost of carrying a core patch across every future update.
This is not confined to the question bank screen
The same filtering code drives random question selection in quizzes. When a learner starts an attempt, Moodle reads the slot’s stored filter and asks the question loader for a question, and that loader builds its query with exactly the same code the bank screen uses. There is no separate implementation for quizzes.
So on 4.3 through 4.5, a random question configured with a Match None tag criterion has been drawing from an incorrectly defined pool. What that means in practice depends on your tag data: the pool may include questions the criterion was written to keep out, it may omit questions that should have been eligible, and where a single tag is used throughout the bank it may be empty. The pool definition is wrong on every attempt, which is not the same as saying every attempt served an excluded question.
If you already built a pool this way
Any quiz on an affected version using a random question with a Match None tag criterion is worth checking. If the excluded questions were excluded for a reason, for instance because they are reserved for a final assessment, they may already have been served to learners. Nothing in the quiz or the bank reports this. The filter returns a plausible looking set and the attempt proceeds normally.
A practical order to work through it:
Find the affected slots. Random question criteria are stored as JSON in
question_set_references.filtercondition. The tag filter is keyedqtagids, and Match None isjointype0. This query finds candidates:SELECT id, itemid, filtercondition FROM mdl_question_set_references WHERE filtercondition LIKE '%"qtagids"%' AND filtercondition LIKE '%"jointype":0%';Read the matches before acting on them. The
LIKEwill also match ajointype0 belonging to a different condition in the same JSON, so confirm that the 0 sits inside theqtagidsobject.Work out the gap. For each affected slot, compare what the criterion was meant to select against what the buggy rule actually returns for your tag data.
Then look at attempts, to see whether any of the wrongly included questions were actually served.
If no current random-question slot uses Match None, there is no live quiz configuration to remediate. That is not quite the same as saying nothing ever went out: a slot that was configured this way and has since been edited or deleted leaves no trace in the current configuration, so if the stakes are high enough, the attempt data is the only place that would still show it.
Under the hood
This section explains the cause in the source. You do not need any of it to apply the fix above, so skip it if you just want the pool working.
The Tag filter builds its SQL in question/bank/tagquestion/classes/tag_condition.php. On 4.3 through 4.5, choosing None flips the comparison inside the subquery:
$equal = !($jointype === datafilter::JOINTYPE_NONE);
[$tagsql, $tagparams] = $DB->get_in_or_equal($selectedtagids, SQL_PARAMS_NAMED, 'param', $equal);
which produces:
q.id IN (SELECT ti.itemid
FROM {tag_instance} ti
WHERE ti.itemtype = 'question'
AND ti.component = 'core_question'
AND ti.tagid NOT IN (:selected)
GROUP BY ti.itemid)
Read that carefully. It selects questions that have a tag row whose tag is not the selected one. A question tagged retired and chapter1 has such a row, so it matches. A question with no tags has no rows at all, so it cannot match. Both surprises fall straight out of the query.
From 5.0 onward the negation moved to the outer clause, which is what the label means:
q.id NOT IN (SELECT ti.itemid
FROM {tag_instance} ti
WHERE ti.itemtype = 'question'
AND ti.component = 'core_question'
AND ti.tagid IN (:selected
The negation is the whole difference: negating set membership is not the same as negating the comparison used to build the set.
Worth noting for anyone maintaining plugins in this area, because it explains how a bug like this can go unnoticed rather than why this particular one did: there were no unit tests for this condition until the fix added tag_condition_test.php. A wrong-results bug with no assertion covering it produces no failing build, and because the filter still returns something, it produces no visible error either. Those two together are what let a defect sit in a shipped feature.
Solin specializes in Moodle question bank and quiz configuration.
Contact us