How to Parse Canvas Gradebook JSON with Pandas

Institutional data teams ingest Canvas gradebook payloads to drive predictive analytics, trigger academic-intervention workflows, and synchronize downstream student information systems. The Canvas REST API delivers gradebook data as deeply nested, paginated JSON that rarely aligns with relational table structures, so a naive pd.json_normalize(response.json()) produces misaligned columns, silent type coercion, and truncated rosters. This guide walks through a deterministic procedure that flattens raw submission objects into a typed, analysis-ready DataFrame while honouring the FERPA compliance boundary before any data is serialized. It builds directly on the Canvas Gradebook Data Structure entity model and slots into the wider LMS Data Architecture & Schema Mapping discipline.

Canvas gradebook parsing pipeline A seven-stage pipeline from raw Canvas JSON to a typed DataFrame. Stage one pages the submissions endpoint by following the Link header rel=next until it disappears. Stage two collects the nested records into a list of dicts. Stage three flattens them with json_normalize and an underscore separator. Stage four coerces columns to nullable Int64, Float64, and boolean dtypes so null stays NA. Stage five branches on whether the grading-period column is present to avoid a KeyError. Stages six and seven sit below the FERPA minimization boundary: stage six hashes the login id with SHA-256 and drops the raw identifier, and stage seven emits a typed DataFrame keyed by the composite grain of assignment id and user id. rel="next" FERPA minimization boundary 1 Page submissions endpoint follow Link header until rel="next" disappears 2 fetch_all_submissions() accumulate nested records as list[dict] 3 json_normalize(sep="_") flatten to assignment_points_possible, user_login_id 4 astype(SCHEMA) — nullable dtypes Int64 / Float64 / boolean · null stays <NA>, never 0 5 Branch on grading-period column keep only if present — no KeyError when MGP is off 6 SHA-256 pseudonymize hash login_id → user_login_id_hash, drop raw PII 7 Typed DataFrame grain = (assignment_id, user_id), analysis-ready

Prerequisites

Confirm each of these before running the procedure — the script assumes all of them are in place.

Step-by-step implementation

1. Page through the submissions endpoint with the Link header, not an offset. Canvas paginates using RFC 5988 Link headers and follows rel="next" until it disappears; an offset loop or a “stop when the page is short” heuristic silently drops rows, because Canvas can return a short page mid-stream. This is the same contract documented in pagination strategies for bulk exports.

2. Set per_page=100 and request only the includes you need. Each include[]=assignment and include[]=user embeds the nested object you will flatten, which avoids a second round-trip per submission. Requesting submission_comments you do not use only inflates the payload and drags PII through your pipeline.

3. Back off on throttling. Canvas signals rate limiting with 403 Forbidden (Rate Limit Exceeded) — not only 429 — so a client that retries on 429 alone aborts the run as if it were an auth error. Pair this parser with Canvas API rate-limit handling for production pacing.

4. Flatten with json_normalize(sep="_") and an explicit record_prefix discipline. Passing the raw list to json_normalize collapses assignment.points_possible into assignment_points_possible. Doing this with a deterministic separator is what keeps column names stable across Canvas API version bumps.

5. Coerce to nullable dtypes, never default-coerce. Use Int64, Float64, and boolean (capitalized, nullable) so that an ungraded submission’s score: null stays <NA> instead of becoming 0.0. Conflating null with zero deflates every affected student’s grade — the single most common gradebook bug.

6. Branch on grading periods before filtering by them. When Multiple Grading Periods is disabled, assignment.grading_period_id is absent entirely; filtering on a missing column raises KeyError. Detect the column’s presence first, mirroring the rules in the weighted grade calculation engines guide.

7. Pseudonymize identifiers before serialization. Hash user_login_id with SHA-256 and drop the raw column before the DataFrame leaves memory. This preserves a stable join key for longitudinal tracking — the same surrogate-key idea behind Cross-LMS Student ID Mapping — while keeping direct identifiers out of downstream storage.

Complete runnable code block

python
import os
import hashlib
import logging
import pandas as pd
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("canvas_gradebook_parser")

# Strict, nullable schema: <NA> is preserved so ungraded != zero.
SCHEMA: dict[str, str] = {
    "id": "Int64",
    "assignment_id": "Int64",
    "assignment_name": "string",
    "assignment_points_possible": "Float64",
    "assignment_grading_type": "string",
    "assignment_grading_period_id": "Int64",
    "user_id": "Int64",
    "user_login_id": "string",
    "score": "Float64",
    "grade": "string",
    "late": "boolean",
    "missing": "boolean",
    "excused": "boolean",
    "posted_at": "datetime64[ns]",
}


def _next_url(link_header: str | None) -> str | None:
    """Extract the rel="next" URL from a Canvas RFC 5988 Link header."""
    if not link_header:
        return None
    for part in link_header.split(","):
        if 'rel="next"' in part:
            return part.split(";")[0].strip("<> ")
    return None


def fetch_all_submissions(api_base: str, token: str, course_id: int) -> list[dict]:
    """Follow Link headers to retrieve every submission for a course."""
    url = f"{api_base}/api/v1/courses/{course_id}/students/submissions"
    headers = {"Authorization": f"Bearer {token}"}
    params = {"include[]": ["assignment", "user"], "student_ids[]": "all", "per_page": 100}
    rows: list[dict] = []
    while url:
        resp = requests.get(url, headers=headers, params=params, timeout=30)
        # Canvas throttles with 403 (Rate Limit Exceeded), not only 429.
        if resp.status_code in (403, 429):
            raise RuntimeError(f"Throttled ({resp.status_code}); add backoff before retrying.")
        resp.raise_for_status()
        rows.extend(resp.json())
        url = _next_url(resp.headers.get("Link"))
        params = {}  # subsequent next-URLs already carry the query string
    logger.info("Retrieved %d submission records for course %d", len(rows), course_id)
    return rows


def normalize_gradebook(submissions: list[dict]) -> pd.DataFrame:
    """Flatten nested submission JSON and enforce the strict schema."""
    if not submissions:
        return pd.DataFrame(columns=list(SCHEMA))
    df = pd.json_normalize(submissions, sep="_")
    # posted_at must be parsed to datetime before astype() can apply the dtype.
    if "posted_at" in df.columns:
        df["posted_at"] = pd.to_datetime(df["posted_at"], errors="coerce", utc=True).dt.tz_localize(None)
    df = df.astype({col: dtype for col, dtype in SCHEMA.items() if col in df.columns})
    # Keep only known columns, in deterministic order, to resist schema drift.
    return df[[c for c in SCHEMA if c in df.columns]]


def apply_ferpa_minimization(df: pd.DataFrame) -> pd.DataFrame:
    """Hash login IDs and drop raw PII before the frame leaves memory."""
    if df.empty:
        return df
    df = df.copy()
    df["user_login_id_hash"] = df["user_login_id"].map(
        lambda x: hashlib.sha256(str(x).encode()).hexdigest() if pd.notna(x) else pd.NA
    )
    return df.drop(columns=["user_login_id"])


def build_gradebook(api_base: str, token: str, course_id: int) -> pd.DataFrame:
    raw = fetch_all_submissions(api_base, token, course_id)
    return apply_ferpa_minimization(normalize_gradebook(raw))


if __name__ == "__main__":
    frame = build_gradebook(
        api_base="https://canvas.instructure.com",
        token=os.environ["CANVAS_TOKEN"],
        course_id=int(os.environ["COURSE_ID"]),
    )
    logger.info("DataFrame shape: %s", frame.shape)
    print(frame.dtypes)
    print(frame.head())

Verification and output validation

Confirm the parser produced a clean, FERPA-safe frame before handing it downstream:

  • Shape and grain. assert len(frame) == frame[["assignment_id", "user_id"]].drop_duplicates().shape[0] — every row is a unique (assignment_id, user_id) pair, the submission grain.
  • No raw identifiers leaked. assert "user_login_id" not in frame.columns and assert frame["user_login_id_hash"].str.len().dropna().eq(64).all() (SHA-256 hex is 64 chars).
  • Null preserved, not zeroed. Pick an ungraded submission and assert pd.isna(frame.loc[mask, "score"]).all() — a 0.0 here would be the classic ungraded-as-zero bug.
  • Dtypes are nullable. assert frame["score"].dtype.name == "Float64" and assert frame["excused"].dtype.name == "boolean"; lowercase float64/bool means a null slipped through as a coerced value.
  • Grading period optional. If MGP is off, assignment_grading_period_id is simply absent — assert frame["assignment_grading_period_id"].isna().all() should hold when the column exists but the course has no periods.

Troubleshooting

  • KeyError: 'assignment_grading_period_id' when filtering by period. MGP is disabled on the course, so Canvas omits the field entirely. Check "assignment_grading_period_id" in frame.columns before filtering; the schema-driven column selection above already tolerates its absence.
  • 401 Unauthorized partway through pagination. A short-lived token expired mid-export, leaving a half-ingested roster. Refresh proactively rather than reactively — wire in automating Canvas API token refresh in Python and call get_valid_token() at the top of each page.
  • 403 Forbidden (Rate Limit Exceeded) treated as an auth failure. Canvas throttles with 403, not only 429. The script raises a distinct RuntimeError for both; layer Canvas API rate-limit handling on top so the run pauses instead of aborting.
  • Truncated DataFrame (fewer rows than the gradebook shows). A loop that stops when a page returns fewer than per_page rows drops mid-stream short pages. Only stop when _next_url() returns None — the rel="next" link is the sole authority on completion.
  • Excused submissions scored as zero downstream. excused: true arrives with score: null. The nullable Float64 dtype keeps it <NA>; ensure any aggregation drops excused rows from both numerator and denominator rather than calling .fillna(0).
  • ValueError: cannot convert ... to Int64 on astype. Canvas occasionally returns IDs as strings or embeds an error object instead of a list when the token lacks grading rights. Inspect submissions[0] — a dict with an errors key means the request was rejected, not paginated.
  • Columns named assignment.points_possible with literal dots. You passed sep="." (or omitted sep); pandas then keeps dotted names that break attribute access. Use sep="_" as shown so assignment_points_possible is a valid identifier.

Part of: Canvas Gradebook Data Structure