Your Moodle Course Will Never Complete: The Empty-Criteria Trap

Completion tracking is enabled, learners are finishing everything, and not one course ever marks complete. Here's the database-level cause, the one-query diagnosis, and a copyable script that rebuilds the completion chain.

If enablecompletion is 1 but mdl_course_completion_criteria has zero rows for the course, completion can never fire — there is nothing to satisfy. The fix: insert an ACTIVITY criterion per completion-enabled module plus a GRADE criterion, add ALL-aggregation rows in course_completion_aggr_methd, then set reaggregate so cron re-evaluates every learner from data they already have.

"moodle course completion not working." "course never marks complete." "moodle certificate not issuing on completion." If you got here through one of those searches, here is the short version: your course almost certainly has completion tracking turned on and zero completion criteria defined — a state Moodle™ lets you reach without a single warning, and in which no learner can ever complete the course. Not slowly. Not eventually. Never.

We hit this on a multi-course certificate program. The hub course was gated to complete only when its member courses completed, and the certificate hung off the hub. Learners finished every activity, passed every quiz, and the whole structure sat frozen: no completions, no certificate, and nothing in any log that looked like an error.

That last part is the trap. Nothing is broken. There is simply nothing to satisfy.

This post walks the full repair: what the completion machinery actually checks, one SQL query that confirms the fault on your site, and the criteria-chain builder we now keep as a standard admin snippet. You should be able to fix this yourself in an afternoon.

The mechanism

Why can completion be enabled and still impossible?

Course completion in Moodle is a chain of four parts, and the settings page only shows you the first one clearly:

  1. enablecompletion on the course — the power switch. This is what "Enable completion tracking: Yes" sets.
  2. mdl_course_completion_criteria — one row per condition that must be met: this activity completed, this grade reached, this many days enrolled. These are the checkboxes on the Course completion settings page, and none of them are checked by default.
  3. mdl_course_completion_aggr_methd — how the criteria combine: ALL of them, or ANY.
  4. The completion cron task, which evaluates the criteria per learner and writes timecompleted into mdl_course_completions.

The switch powers the chain. The criteria are the chain. With zero rows in the criteria table, the cron task has a list of zero conditions to check, checks all zero of them, and marks nobody — because "complete" is defined as "all criteria satisfied" and there are no criteria to satisfy. An empty rule set does not mean "everyone completes." It means the evaluation never produces a completion at all.

course setting enablecompletion = 1 the power switch: ON mdl_course_completion_criteria 0 rows nothing to satisfy completion cron criteria met: 0 of 0 marks nobody course_completions.timecompleted stays NULL for every learner, forever the switch is on, but there is no circuit
The broken chain: tracking enabled, but with zero rows in mdl_course_completion_criteria there is nothing for the cron aggregator to satisfy — and any hub course gated on these courses is frozen along with them.

How do courses end up here? Easily. The Course completion page is a separate screen most people never open — enabling tracking in the course settings feels like the whole job. Courses restored from backup, duplicated, or created through web services routinely land with the flag on and the criteria table empty. Nobody notices until someone asks where the certificates are.

Honesty section: we did not find this on the first pass. We spent it on the certificate activity, which had two genuine faults of its own — it was restricted to membership of a group that had no members in it, and the course holding it was hidden. Both real, both worth fixing, and fixing both changed nothing, because upstream the member courses could never complete in the first place. Stacked faults are normal in inherited Moodle sites. Fix the chain from the source down.

Diagnosis

How do I confirm this on my own site?

One query, straight from our runbook. It lists every course that claims to track completion, alongside how many criteria it actually has:

SELECT c.id, c.enablecompletion,
  (SELECT COUNT(*) FROM mdl_course_completion_criteria WHERE course=c.id) AS crit
FROM mdl_course c WHERE c.enablecompletion=1;   -- crit=0 is the smoking gun

crit=0 is the smoking gun. Any course in that state is unfinishable by construction, no matter what learners do.

You can confirm the same thing without database access: open the course, go to Course completion in the settings, and look at what is actually ticked. If every condition is unchecked, that is your zero rows. The SQL matters because it checks the whole site at once — on our certificate program, every member course had the fault, and clicking through them one by one would have hidden the pattern.

While you are in there, check the downstream gates too. If a certificate or final activity is involved, look at its access restrictions (mdl_course_modules.availability holds the JSON) and at whether the course is visible at all. Our certificate was gated on an empty group inside a hidden course — three locks on one door.

The fix

How do I add the missing completion criteria?

For a single course, use the UI and go home early: Course completion settings, tick the activities that should count, add a grade condition if you want one, save, done. No script beats a checkbox for one course.

The scripted version is for when the fault is plural — we had a whole program of member courses to repair identically. Per course, the pattern is: one ACTIVITY criterion for each completion-enabled module, plus a GRADE criterion, aggregated ALL:

foreach ($mods as $cm) {
    $DB->insert_record('course_completion_criteria', (object)[
        'course'=>$cid, 'criteriatype'=>COMPLETION_CRITERIA_TYPE_ACTIVITY,
        'module'=>$cm->modname, 'moduleinstance'=>$cm->cmid ]);
}
$DB->insert_record('course_completion_criteria', (object)[
    'course'=>$cid, 'criteriatype'=>COMPLETION_CRITERIA_TYPE_GRADE,
    'gradepass'=>round($grademax*0.70, 5) ]);

foreach ([null, COMPLETION_CRITERIA_TYPE_ACTIVITY] as $ctype) {
    $DB->insert_record('course_completion_aggr_methd', (object)[
        'course'=>$cid, 'criteriatype'=>$ctype,
        'method'=>COMPLETION_AGGREGATION_ALL ]);
}
$DB->execute("UPDATE {course_completions} SET reaggregate=? WHERE course=?",
             [time(), $cid]);

Four things worth understanding rather than pasting blind:

  • The ACTIVITY loop inserts one criterion per completion-enabled module — build $mods from the course's module list, keeping only modules whose own completion tracking is on. A criterion pointing at a module with tracking off can never be met, and you are back where you started.
  • The GRADE criterion sets the course pass mark at 70% of the course grade maximum, rounded to 5 decimal places because that is the column's precision. Adjust the 0.70 to your program's actual bar.
  • The aggregation rows are the part everyone forgets. Two rows: one with criteriatype NULL (the overall method) and one for the ACTIVITY type, both set to COMPLETION_AGGREGATION_ALL. Criteria without aggregation methods leave the evaluator undefined.
  • The reaggregate update is what makes this fair to existing learners. Setting it to the current time flags every enrolment in the course for re-evaluation on the next completion cron run — against activity and grade data learners already have. Anyone who already did the work flips to complete without redoing anything.

Run it as a CLI script under the Moodle root so the COMPLETION_* constants and $DB are defined (require config.php, include lib/completionlib.php).

course_completion_criteria ACTIVITY — Quiz 1 module + cmid ACTIVITY — Quiz 2 module + cmid ACTIVITY — Assignment module + cmid GRADE — 70% of max gradepass aggregation: ALL course_completion_aggr_methd course COMPLETE timecompleted written by cron certificate gate opens {"type":"coursecompleted"}
The rebuilt chain: one ACTIVITY criterion per completion-enabled module plus one GRADE criterion, combined under ALL aggregation. Only now can the cron write timecompleted — and only then can a completion-gated certificate appear.

Then repair the downstream gates: change the certificate activity's restriction from the group condition to {"type":"coursecompleted"} so it is gated on completing its course, and unhide the course. All three locks off the door.

Verification

How do I know the fix worked?

Trigger the completion cron (or wait for its next scheduled run) so the reaggregate flags get processed. Then pick one learner you know finished everything and check them specifically: their row in mdl_course_completions should now carry a timecompleted, the course completion report should show every criterion ticked, and the certificate should be visible to them. One known-good learner flipping to complete proves the whole chain end to end. Zero learners flipping means a criterion is unmeetable — see the gotchas below.

Before you flip anything: if the certificate has emailstudents=1, repairing completion can fire an immediate batch of "your certificate" emails to every learner who becomes eligible at once — in our case, everyone who had ever finished the coursework. Sometimes that is exactly what you want; a backdated program suddenly making good on its certificates is a fine look. But decide on purpose. If you would rather announce it yourself, switch the setting off before the cron run and back on after.

Gotchas

Why is a learner with a 90% average still incomplete?

Because of ALL aggregation meeting per-activity rules. In our program, each quiz had its own activity-completion rule of "complete on pass" with a 65% bar — and that sits under the course-level 70% grade criterion, as a separate condition. A learner can average 90% across the course and still be incomplete, because one quiz came in at 61% and its ACTIVITY criterion is therefore unmet. Two thresholds, evaluated independently, and the stricter interpretation always wins under ALL.

90 88 61 95 Quiz 1 Quiz 2 Quiz 3 Quiz 4 per-quiz pass: 65% course average: 83.5% GRADE criterion (70%): met Quiz 3 ACTIVITY: unmet ALL aggregation: INCOMPLETE
The 65/70 trap: an 83.5% average clears the course GRADE criterion, but one 61% quiz fails its own complete-on-pass rule — and under ALL aggregation, one unmet ACTIVITY criterion holds the whole course at incomplete.

Our opinion: this is usually the behavior a certificate program actually wants — a certificate should mean every gate was cleared, not that the average papered over a failed module. But it must be the program's decision, not an accident. If it is not what you want, either relax the per-quiz pass rules or set the ACTIVITY aggregation row to ANY, and write down which you chose.

Two more edge cases from the same job:

  • Hub courses cascade in order. A hub gated on member-course completion only moves after the member courses have reaggregated. Fix the members, let cron pass over them, then expect the hub — and its certificate — to follow on a subsequent run. If you check the hub five minutes after fixing the members, it will still look broken. It isn't. Give the chain time to propagate.
  • Criteria are a snapshot, not a subscription. Adding a new activity to the course later does not add a criterion for it. If the course grows, revisit the Course completion page (or re-run the builder) or your completion definition quietly drifts away from the course content.

If you'd rather hand it off

Everything above is enough to repair this yourself — the query finds the fault, the script fixes it, and the verification steps prove it. That's deliberate. But if you're staring at a whole catalog of crit=0 courses, or the completion chain is tangled up with custom plugins and half-inherited configuration, this is squarely the kind of work our Moodle development service does: we audit the completion chain across the site, rebuild it, and hand you the evidence that a known-finished learner completes end to end. Tell us what you're seeing and we'll reply within one business day with an honest read on whether you need us at all.

Quick answers

Questions people ask about this

Why does my Moodle course never mark as complete even though completion tracking is enabled?

Enabling completion tracking only turns the feature on; it does not define what completion means. If the course has zero rows in mdl_course_completion_criteria, there is nothing to satisfy and no learner can ever complete. Add at least one criterion on the Course completion settings page, then let cron reaggregate.

How do I check whether a course has any completion criteria defined?

In the UI, open Course completion in the course settings and see whether any condition is actually ticked. In the database, count rows in mdl_course_completion_criteria for the course id. A course with enablecompletion set to 1 and a criteria count of 0 is the empty-criteria trap.

Do I need to write SQL to fix this, or can I use the Moodle interface?

For one course the UI is fine: open Course completion settings, tick the activities and grade condition you want, and save. The scripted approach matters when many courses share the fault, because the criteria rows, the aggregation method rows, and the reaggregate flag all have to be set per course.

Will existing learners get credit after I add completion criteria?

Yes. Setting the reaggregate field on mdl_course_completions makes the next completion cron run re-evaluate learners against the new criteria using activity and grade data they already have. Learners who already meet everything flip to complete without redoing any work.

Why is my certificate still not issuing after the course marks complete?

Check the certificate activity's own access restrictions. In the case we repaired, the certificate was restricted to a group with no members and the course holding it was hidden, so nobody could ever reach it. Gate the certificate on course completion and make the course visible. Also note that emailstudents can send a batch of certificate emails the moment learners become eligible.

Can a learner have a passing course grade and still be incomplete?

Yes, under ALL aggregation. If a quiz has its own complete-on-pass rule, a learner who fails that one quiz misses its ACTIVITY criterion even with a 90 percent course average. Either accept that behavior, relax the per-quiz rule, or switch the activity aggregation method to ANY.