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.
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.
The same logic means not every removal is equal. Here is the fate of grades per action:
| Action | Effect on mdl_grade_grades | Recoverable? |
|---|---|---|
| Suspend enrolment | Untouched | Nothing lost in the first place |
| Full unenrol | Rows deleted | Yes — archived in mdl_grade_grades_history |
| Plain re-enrol afterwards | Stays empty | Recovery exists but is not triggered |
| Re-enrol with $recovergrades = true | Repopulated 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);
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.
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.