Moodle Grades Gone After Re-enrolment? They're Not — Recover Them From History

A full unenrolment deletes live grades but archives every one of them in the grade history table. Here's how to confirm they're still there and copy them back — no recompute, no restore from backup.

A full unenrolment deletes a learner's rows from mdl_grade_grades but archives every one in mdl_grade_grades_history. Re-enrolling alone doesn't restore them because the recovergradesdefault setting is off by default. Fix: re-enrol with enrol_user()'s seventh argument ($recovergrades) set to true, or call grade_recover_history_grades($userid, $courseid) for already-enrolled users. Grades return from history without recomputation.

The learner did the work. Their completion emails are sitting in your archive. And the course total in the gradebook is a dash.

If you found this by googling "moodle grades gone after unenrolment" or "re-enrol student grades disappeared", here is the short version: the grades are almost certainly not gone. Moodle™ archived them at the moment of unenrolment, and it will hand them back — but only if you ask in a very specific way, because the recovery feature is off by default.

We hit this on a client site where a cohort's enrolments had been removed by an annual expiry — roughly 480 learners across four courses. When access was restored, every one of those learners had blank course totals, even though they had genuinely earned their grades months earlier.

What didn't work: re-enrolling them. Plain re-enrolment — through the UI, through CSV upload, through the API without the right flag — brought back access and nothing else. The gradebook stayed empty, which is exactly the moment most admins start pricing out a database restore.

Don't restore from backup. This is a two-line fix.

Root cause

What actually happens to grades when you unenrol someone

A full unenrol deletes the learner's rows from mdl_grade_grades — the live table the gradebook reads. But before each row is deleted, Moodle writes a copy into mdl_grade_grades_history. Every grade, every item, timestamped. The data survives; only the live view of it is destroyed.

Moodle even ships the matching recovery routine, grade_recover_history_grades(), wired into the enrolment path. The catch is that the trigger is opt-in: the site setting recovergradesdefault (Site administration → Grades → General settings, "Recover grades default") is off out of the box. So a plain re-enrol re-creates the enrolment, checks the setting, finds it off, and walks straight past the history table.

1 · Full unenrol 2 · Plain re-enrol 3 · Recover mdl_grade_grades — what the gradebook shows rows deleted still empty restored mdl_grade_grades_history — the archive unenrol writes rows copied to history, then deleted from live recovergradesdefault = 0 nothing comes back grade_recover_history_grades() copies the latest row per item
The unenrol → history → recover flow. The deletion is real, but so is the archive — recovery just never runs unless you ask for it.

The same logic means not every removal is equal. Here is the fate of grades per action:

ActionEffect on mdl_grade_gradesRecoverable?
Suspend enrolmentUntouchedNothing lost in the first place
Full unenrolRows deletedYes — archived in mdl_grade_grades_history
Plain re-enrol afterwardsStays emptyRecovery exists but is not triggered
Re-enrol with $recovergrades = trueRepopulated from history

Diagnosis

How to confirm the grades are still in the history table

Before touching anything, prove the pattern on your own database: live grades empty, history present. For one affected user and course:

SELECT COUNT(*) FROM mdl_grade_grades_history ggh
  JOIN mdl_grade_items gi ON gi.id=ggh.itemid
 WHERE gi.courseid=? AND ggh.userid=? AND ggh.finalgrade IS NOT NULL;

A count greater than zero means the earned grades are sitting in the archive. Then confirm the live side really is empty rather than just hidden by a display setting:

SELECT COUNT(*) FROM mdl_grade_grades gg
  JOIN mdl_grade_items gi ON gi.id=gg.itemid
 WHERE gi.courseid=? AND gg.userid=? AND gg.finalgrade IS NOT NULL;

History rows present, live rows zero: you're in exactly the situation this post fixes. If both queries come back zero, the grades were never written or history was purged — that's a different (and much worse) problem, and recovery has nothing to work from.

The fix

How to recover grades from grade history

There are two routes, both Moodle-native. Neither recomputes anything — they copy the stored values straight back.

Route A: you haven't re-enrolled them yet

Enrol with grade recovery. The switch is the seventh argument of enrol_user():

$plugin->enrol_user($instance, $uid, $roleid, $start, $end,
                    ENROL_USER_ACTIVE, /*recovergrades*/ true);
$plugin->enrol_user($instance, $uid, $roleid, $start, $end, ENROL_USER_ACTIVE, true); argument 7: $recovergrades defaults to the recovergradesdefault site setting — which is off out of the box
The flag everyone misses. Leave argument 7 off and the call quietly inherits the site default: no recovery.

Route B: they're already re-enrolled

You don't need to unenrol and start over. Call the exact function the enrolment path would have used:

grade_recover_history_grades($userid, $courseid);

For a batch — our 480-learner case — a short CLI script in your Moodle root does it:

<?php
define('CLI_SCRIPT', true);
require(__DIR__ . '/config.php');
require_once($CFG->libdir . '/gradelib.php');

$courseid = 1234;                 // the affected course
$userids  = [/* affected user ids */];

foreach ($userids as $uid) {
    grade_recover_history_grades($uid, $courseid);
    mtrace("Recovered grades for user {$uid}");
}

Two properties make this the right tool. It restores mdl_grade_grades from history rather than recomputing from activity attempts — so it works even if the gradebook items were purged, and even where activities have changed since. And it's per-user, per-course, so you control the blast radius: one test account first, then the batch.

Verification

How to check the recovered grades are correct

The course totals repopulate to the historical values immediately — open the grader report for a recovered user and the dashes should be numbers again. Don't stop there. Spot-check that what came back equals the most recent history row per item:

SELECT gi.itemname, gg.finalgrade AS recovered,
       (SELECT ggh.finalgrade
          FROM mdl_grade_grades_history ggh
         WHERE ggh.itemid = gg.itemid AND ggh.userid = gg.userid
           AND ggh.finalgrade IS NOT NULL
         ORDER BY ggh.timemodified DESC LIMIT 1) AS latest_history
  FROM mdl_grade_grades gg
  JOIN mdl_grade_items gi ON gi.id = gg.itemid
 WHERE gi.courseid = ? AND gg.userid = ?;

recovered and latest_history should match line for line. On our four courses they did, for every sampled learner — which is the moment you can tell the client their records were never actually lost.

Gotchas

Why you shouldn't just switch on recovergradesdefault site-wide

The obvious "fix" is to flip recovergradesdefault on globally so this never happens again. We considered it and decided against it, and we'd advise the same for any site of size.

The global setting is heavy. With recovery on by default, every single enrolment probes every module in the course for every user being enrolled. Combined with broken grade items, that behaviour has caused real site load problems in the wild. Prefer the per-enrol flag, or the per-user function run in controlled batches — same result, no standing tax on every enrolment your site ever processes.

The second gotcha is subtler: recovery pulls the latest history row per item. If anything wrote to the gradebook between the real grades being earned and the unenrolment — a stray sync, a reset, an import gone wrong — that later row is what comes back, not the grade the learner remembers. This is why the verification query above orders by timemodified: you want to see what "latest" means on your data before you recover 480 people.

mdl_grade_grades_history — one grade item, one user, oldest first Mar 14 · finalgrade 86.00 Jun 02 · finalgrade 92.00 — the grade they earned Jul 09 · finalgrade 0.00 — stray write after the fact recovery copies the latest row per item check what that is first
The latest-row rule. Recovery doesn't know which row is the "right" one — it takes the newest. Audit before you run it in bulk.

And one piece of prevention: if enrolments on your site expire on a schedule, set the enrolment plugin's expiry action to suspend (or keep) rather than full unenrol. A suspended learner loses access but keeps their live grade rows — and this entire article becomes unnecessary.

If you'd rather not run SQL against your gradebook

Everything above is genuinely enough to do this yourself — that's why we wrote it down. But if it's hundreds of learners across multiple courses, or the history data doesn't look clean, or there's an audit date attached, this is routine work for us: it's the same Moodle development and gradebook surgery we do year-round, and grade recovery jobs come with the before/after evidence queries included. Tell us what happened and we'll reply within one business day with an honest read on whether you even need us.

Quick answers

Questions people ask about this

Does unenrolling a student from a Moodle course delete their grades?

A full unenrolment deletes the learner's rows from mdl_grade_grades, so the gradebook goes blank. But Moodle archives every deleted row into mdl_grade_grades_history first, so the data survives. Suspending an enrolment, by contrast, leaves live grades untouched — which is why we recommend suspend over unenrol for scheduled expiries.

Why didn't the grades come back when I re-enrolled the student?

Because grade recovery is opt-in. The site setting recovergradesdefault is off by default, and a plain re-enrolment through the UI, CSV upload, or API respects that default. Unless the enrolment call passes the recover-grades flag, Moodle leaves the history rows exactly where they are.

How do I restore grades for a user who is already re-enrolled?

Call grade_recover_history_grades($userid, $courseid) from a CLI script — it is the exact function the enrolment path uses when recovery is enabled. It copies the latest history row for each grade item back into the live gradebook. No need to unenrol and re-enrol again.

Should I turn on recovergradesdefault site-wide?

We advise against it on sites of any size. With it on, every enrolment probes every module in the course for every user, and combined with broken grade items that has caused real site load problems. Passing the per-enrolment flag, or running the recovery function for affected users in batches, gives the same result with far more control.

Which grade does recovery restore if there are multiple history rows for an item?

The most recent history row per grade item. If anything wrote to the gradebook after the real grades were earned — a stray sync, a reset, a bad import — that later row is what comes back. Query the history ordered by timemodified and spot-check a few users before recovering in bulk.

Does grade recovery work if the course activities were changed or gradebook items purged?

Yes, within limits. It restores stored values from mdl_grade_grades_history rather than recomputing from activity attempts, so it works even where gradebook items were purged or activities have changed since. What it cannot do is recreate grades for items that have no history rows at all.