Course Schema Framework
Use conditional-logic schema generation to keep Course markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralCourse documentationUse for a list of at least three real courses: a series or unit of curriculum with lectures, lessons, or modules, an explicit educational outcome, and one or more instructors with a roster of students. A general public event or a single brief tutorial video is not a course. Course markup must be combined with carousel (ItemList) markup to be eligible for the course list rich result.
Key Implementation Documentation Highlights
- Required on Course: name (the course title) and description. Recommended: provider, the Organization that publishes the course content.
- Mark up at least three courses, using carousel markup on a summary page (ListItem position and a unique url per course detail page) or on an all-in-one page (ListItem position and the Course in item).
- name must be a real course title: no promotional phrases, prices, discounts, or non-course titles such as "Make money fast".
- description has a display limit of 60 characters; longer text may be truncated.
- The course list rich result is available in English in all regions where Google Search is available.
- The separate Course info rich result (course details such as instances, schedules, and offers) is retired: its phase-out was announced on Jun 12, 2025, and it was removed from Search, Search Console reporting, and the Rich Results Test on Sep 9, 2025. Properties such as hasCourseInstance and offers no longer earn a Google rich result.
- Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability.
Rules the framework enforces before it emits markup:
- Course names that contain prices, currency amounts, discount language, or superlatives such as "best" or "top" are suppressed, not cleaned up.
- A description over 60 characters falls back to a visible short summary when one exists.
- The summary page list needs at least three course URLs, all absolute, unique, and on one domain; positions run 1..n.
- A detail page emits Course only when a summary page lists that course, since a lone Course is not shown as a course list.
Template Approach
const pageData = {
title: "Introduction to Statistics",
description: "Probability, sampling, testing, and regression.",
providerName: "Example University",
providerUrl: "https://www.example.edu"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Course",
"name": pageData.title,
"description": pageData.description,
"provider": { "@type": "Organization", "name": pageData.providerName, "sameAs": pageData.providerUrl }
};
{
"@context": "https://schema.org",
"@type": "Course",
"name": "Introduction to Statistics",
"description": "Probability, sampling, testing, and regression.",
"provider": {
"@type": "Organization",
"name": "Example University",
"sameAs": "https://www.example.edu"
}
}
Conditional-logic Framework
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
function isAbsoluteUrl(value) {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && Boolean(url.hostname);
} catch {
return false;
}
}
function isRealDate(y, m, d) {
const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d)));
return date.getUTCMonth() === Number(m) - 1 && date.getUTCDate() === Number(d);
}
// Calendar date (YYYY-MM-DD); a full date-time is cut to its date part.
function toIsoDate(value) {
if (typeof value !== "string") return null;
const v = value.trim();
const m = v.match(ISO_DATE) || v.match(ISO_DATE_TIME);
return m && isRealDate(m[1], m[2], m[3]) ? v.slice(0, 10) : null;
}
// Date-time with Z or an offset (kept as written), or a plain date.
// A date-time without a timezone is rejected instead of guessed.
function toIsoDateTime(value) {
if (typeof value !== "string") return null;
const v = value.trim();
const m = v.match(ISO_DATE) || v.match(ISO_DATE_TIME);
return m && isRealDate(m[1], m[2], m[3]) ? v : null;
}
// Drop null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((v) => v !== undefined);
return items.length ? items : undefined;
}
if (value && typeof value === "object") {
const out = {};
for (const [key, v] of Object.entries(value)) {
const c = compact(v);
if (c !== undefined) out[key] = c;
}
return Object.keys(out).length ? out : undefined;
}
if (value === null || value === undefined || value === "") return undefined;
return value;
}
// Page gates: indexable, self-canonical, and the marked-up content is visible.
function passesPageGates(source) {
if (!source.isIndexable || !source.isContentVisible) return false;
if (!isAbsoluteUrl(source.url)) return false;
return !source.canonicalUrl || source.canonicalUrl === source.url;
}
const PROMO = /[$€£]\s?\d|\d+\s?%\s?off|\b(sale|discount|deal|cheap|free|best|top|only|make money)\b/i;
const DESCRIPTION_DISPLAY_LIMIT = 60;
// A real course title: no prices, discounts, superlatives or non-course phrases.
function isCourseName(name) {
return typeof name === "string" && name.trim() !== "" && !PROMO.test(name);
}
// Google shows about 60 characters; prefer a short summary when the full text is longer.
function pickDescription(course) {
const full = (course.description || "").trim();
const short = (course.shortDescription || "").trim();
if (full.length > DESCRIPTION_DISPLAY_LIMIT && short && short.length <= DESCRIPTION_DISPLAY_LIMIT) return short;
return full || short || null;
}
// Course detail page (summary pattern): one Course node per detail page.
function buildCourseSchema(source) {
if (!passesPageGates(source)) return null;
// Course markup only earns a course list when a summary page lists the course.
if (!isAbsoluteUrl(source.listedOnUrl)) return null;
if (!isCourseName(source.name)) return null;
const description = pickDescription(source);
if (!description) return null;
const provider = source.provider || {};
return compact({
"@context": "https://schema.org",
"@type": "Course",
"name": source.name.trim(),
"description": description,
"provider": provider.name ? {
"@type": "Organization",
"name": provider.name,
"sameAs": isAbsoluteUrl(provider.url) ? provider.url : null
} : null
}) || null;
}
// Summary page: an ItemList of at least three course detail URLs.
// Usage: buildCourseListSchema({ ...pageGates, url, courseUrls: [url1, url2, url3] })
function buildCourseListSchema(source) {
if (!passesPageGates(source)) return null;
const urls = (source.courseUrls || []).filter(isAbsoluteUrl);
if (urls.length < 3 || urls.length !== (source.courseUrls || []).length) return null;
if (new Set(urls).size !== urls.length) return null; // unique detail URLs
if (new Set(urls.map((u) => new URL(u).hostname)).size !== 1) return null; // one domain
return {
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": urls.map((url, index) => ({ "@type": "ListItem", "position": index + 1, "url": url }))
};
}
// Usage
// const source = {
// "isIndexable": true,
// "isContentVisible": true,
// "url": "https://www.example.com/courses/intro-to-statistics",
// "canonicalUrl": "https://www.example.com/courses/intro-to-statistics",
// "listedOnUrl": "https://www.example.com/courses/",
// "name": "Introduction to Statistics",
// "description": "A twelve-week course covering probability, sampling, hypothesis testing, and regression, with weekly problem sets.",
// "shortDescription": "Probability, sampling, testing, and regression.",
// "provider": {
// "name": "Example University",
// "url": "https://www.example.edu"
// }
// };
// const jsonLd = buildCourseSchema(source); // null means: emit nothing
import re
from datetime import date
from urllib.parse import urlparse
ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
ISO_DATE_TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$")
def is_absolute_url(value):
if not isinstance(value, str):
return False
try:
parsed = urlparse(value)
except ValueError:
return False
return parsed.scheme in ("http", "https") and bool(parsed.hostname)
def is_real_date(y, m, d):
try:
date(int(y), int(m), int(d))
return True
except ValueError:
return False
# Calendar date (YYYY-MM-DD); a full date-time is cut to its date part.
def to_iso_date(value):
if not isinstance(value, str):
return None
v = value.strip()
m = ISO_DATE.match(v) or ISO_DATE_TIME.match(v)
return v[:10] if m and is_real_date(m[1], m[2], m[3]) else None
# Date-time with Z or an offset (kept as written), or a plain date.
# A date-time without a timezone is rejected instead of guessed.
def to_iso_date_time(value):
if not isinstance(value, str):
return None
v = value.strip()
m = ISO_DATE.match(v) or ISO_DATE_TIME.match(v)
return v if m and is_real_date(m[1], m[2], m[3]) else None
# Drop None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [c for c in (compact(v) for v in value) if c is not None]
return items or None
if isinstance(value, dict):
out = {}
for key, v in value.items():
c = compact(v)
if c is not None:
out[key] = c
return out or None
if value is None or value == "":
return None
return value
# Page gates: indexable, self-canonical, and the marked-up content is visible.
def passes_page_gates(source):
if not source.get("isIndexable") or not source.get("isContentVisible"):
return False
if not is_absolute_url(source.get("url")):
return False
return not source.get("canonicalUrl") or source.get("canonicalUrl") == source.get("url")
PROMO = re.compile(r"[$€£]\s?\d|\d+\s?%\s?off|\b(sale|discount|deal|cheap|free|best|top|only|make money)\b", re.I)
DESCRIPTION_DISPLAY_LIMIT = 60
# A real course title: no prices, discounts, superlatives or non-course phrases.
def is_course_name(name):
return isinstance(name, str) and name.strip() != "" and not PROMO.search(name)
# Google shows about 60 characters; prefer a short summary when the full text is longer.
def pick_description(course):
full = (course.get("description") or "").strip()
short = (course.get("shortDescription") or "").strip()
if len(full) > DESCRIPTION_DISPLAY_LIMIT and short and len(short) <= DESCRIPTION_DISPLAY_LIMIT:
return short
return full or short or None
# Course detail page (summary pattern): one Course node per detail page.
def build_course_schema(source):
if not passes_page_gates(source):
return None
# Course markup only earns a course list when a summary page lists the course.
if not is_absolute_url(source.get("listedOnUrl")):
return None
if not is_course_name(source.get("name")):
return None
description = pick_description(source)
if not description:
return None
provider = source.get("provider") or {}
return compact({
"@context": "https://schema.org",
"@type": "Course",
"name": source["name"].strip(),
"description": description,
"provider": {
"@type": "Organization",
"name": provider["name"],
"sameAs": provider.get("url") if is_absolute_url(provider.get("url")) else None,
} if provider.get("name") else None,
})
# Summary page: an ItemList of at least three course detail URLs.
# Usage: build_course_list_schema({**page_gates, "url": url, "courseUrls": [url1, url2, url3]})
def build_course_list_schema(source):
if not passes_page_gates(source):
return None
raw = source.get("courseUrls") or []
urls = [u for u in raw if is_absolute_url(u)]
if len(urls) < 3 or len(urls) != len(raw):
return None
if len(set(urls)) != len(urls):
return None # unique detail URLs
if len({urlparse(u).hostname for u in urls}) != 1:
return None # one domain
return {
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": [{"@type": "ListItem", "position": i + 1, "url": u} for i, u in enumerate(urls)],
}
# Usage: json_ld = build_course_schema(source) # same source dict as the JS example; None means emit nothing
{
"@context": "https://schema.org",
"@type": "Course",
"name": "Introduction to Statistics",
"description": "Probability, sampling, testing, and regression.",
"provider": {
"@type": "Organization",
"name": "Example University",
"sameAs": "https://www.example.edu"
}
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Emits nothing when a course title is promotional or a description is missing, instead of shipping markup Google will ignore.
- Builds the summary page list and the detail page Course nodes from the same catalog data, so the two never drift apart.
- Enforces the three-course minimum at build time rather than discovering it in Search Console.
- Keeps retired Course info fields out of the output by default, so teams do not maintain markup with no Google rich result.
Property Reference
What Google's Course list documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
| Property | Expects | Notes | |
|---|---|---|---|
| name | Required | Text | The title of the course. |
| description | Required | Text | A description of the course (display limit of 60 characters). |
| provider | Recommended | Organization | The organization that publishes the source content of the course, for example UC Berkeley. |