Movie Schema Framework
Use conditional-logic schema generation to keep Movie markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralMovie documentationUse on a list of movies: either a summary page whose ListItem entries point to movie detail pages, or a single all-in-one page that carries the full Movie data for every item.
Key Implementation Documentation Highlights
- Availability: the movie carousel is limited to mobile devices and requires an ItemList of movies that follows the carousel guidelines.
- Required Movie properties: name, image.
- Recommended Movie properties: aggregateRating, dateCreated, director, review.
- image must be high resolution, in .jpg, .png or .gif format, crawlable and indexable, with a 6:9 aspect ratio. Google can crop images close to 6:9; images that deviate a lot are not eligible.
- ItemList: each ListItem needs a position starting at 1 and increasing by 1, plus either url (summary page, pointing to a detail page on the same domain) or item with the full Movie (all-in-one page, item.url is the page URL plus a #anchor). All items must be Movies.
- dateCreated is the release date in ISO 8601. director is a Person. review and aggregateRating follow the Review snippet guidelines (rating values within bestRating and worstRating).
- Summary page approach: every detail page carries its own Movie markup with the required properties. Validate with the Rich Results Test before deploying.
Template Approach
const pageData = {
pageUrl: "https://www.example.com/lists/best-heist-films-2026",
movies: [
{ anchor: "the-vault-line", name: "The Vault Line", poster: "https://www.example.com/posters/the-vault-line.jpg", released: "2026-02-13", director: "Maya Ortiz" },
{ anchor: "night-transfer", name: "Night Transfer", poster: "https://www.example.com/posters/night-transfer.png", released: "2026-05-08", director: "Daniel Okafor" }
]
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": pageData.movies.map((m, i) => ({
"@type": "ListItem",
"position": i + 1,
"item": {
"@type": "Movie",
"url": pageData.pageUrl + "#" + m.anchor,
"name": m.name,
"image": m.poster,
"dateCreated": m.released,
"director": { "@type": "Person", "name": m.director }
}
}))
};
{
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"item": {
"@type": "Movie",
"url": "https://www.example.com/lists/best-heist-films-2026#the-vault-line",
"name": "The Vault Line",
"image": "https://www.example.com/posters/the-vault-line.jpg",
"dateCreated": "2026-02-13",
"director": {
"@type": "Person",
"name": "Maya Ortiz"
}
}
},
{
"@type": "ListItem",
"position": 2,
"item": {
"@type": "Movie",
"url": "https://www.example.com/lists/best-heist-films-2026#night-transfer",
"name": "Night Transfer",
"image": "https://www.example.com/posters/night-transfer.png",
"dateCreated": "2026-05-08",
"director": {
"@type": "Person",
"name": "Daniel Okafor"
}
}
}
]
}
Conditional-logic Framework
// Shared helpers
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const ISO_DATETIME = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-](0\d|1[0-4]):[0-5]\d)$/;
function isAbsoluteUrl(value) {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
} catch {
return false;
}
}
function toIsoDate(value) {
const match = typeof value === "string" ? ISO_DATE.exec(value) : null;
if (!match) return null;
const [y, m, d] = match.slice(1).map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
return date.getUTCMonth() === m - 1 && date.getUTCDate() === d ? value : null;
}
// Keeps the original timezone offset; rejects naive or unparseable values.
function toIsoDateTime(value) {
const match = typeof value === "string" ? ISO_DATETIME.exec(value) : null;
return match && toIsoDate(match[1]) ? value : null;
}
// Drops null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((item) => item !== undefined);
return items.length ? items : undefined;
}
if (value !== null && typeof value === "object") {
const out = {};
for (const [key, item] of Object.entries(value)) {
const kept = compact(item);
if (kept !== undefined) out[key] = kept;
}
return Object.keys(out).length ? out : undefined;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
// Page gates: indexable, self-canonical, marked-up content visible.
function passesPageGates(page) {
if (!page || page.noindex || page.robotsBlocked) return false;
if (!isAbsoluteUrl(page.url)) return false;
if (page.canonicalUrl && page.canonicalUrl !== page.url) return false;
return page.contentVisible === true;
}
// Movie helpers
const IMAGE_FILE = /\.(jpe?g|png|gif)$/i;
// 6:9 (2:3) poster ratio; Google can crop images that are close to it.
function isPosterRatio(img) {
if (!img || !img.width || !img.height) return false;
return Math.abs(img.width / img.height - 6 / 9) <= 0.05;
}
function buildPerson(name) {
return name ? { "@type": "Person", name } : null;
}
function buildRating(r, isAggregate) {
if (!r) return null;
const best = r.bestRating ?? 5;
const worst = r.worstRating ?? 1;
if (typeof r.ratingValue !== "number" || r.ratingValue < worst || r.ratingValue > best) return null;
if (isAggregate && !(Number.isInteger(r.ratingCount) && r.ratingCount > 0)) return null;
return {
"@type": isAggregate ? "AggregateRating" : "Rating",
ratingValue: r.ratingValue,
bestRating: best,
worstRating: worst,
ratingCount: isAggregate ? r.ratingCount : null
};
}
function buildReview(review) {
const rating = buildRating(review && review.rating, false);
if (!rating || !review.authorName) return null; // review needs author and rating
return {
"@type": "Review",
reviewRating: rating,
author: buildPerson(review.authorName),
datePublished: toIsoDate(review.datePublished)
};
}
// One Movie node. Required: name, image (crawlable .jpg/.png/.gif, about 6:9).
// Summary approach detail page: compact({ "@context": "https://schema.org", ...buildMovieSchema(movie) })
function buildMovieSchema(movie) {
const images = (movie.images || []).filter(
(img) => isAbsoluteUrl(img.url) && IMAGE_FILE.test(new URL(img.url).pathname) && isPosterRatio(img)
);
if (!movie.name || !images.length) return null;
return {
"@type": "Movie",
url: isAbsoluteUrl(movie.url) ? movie.url : null,
name: movie.name,
image: images.map((img) => img.url),
dateCreated: toIsoDate(movie.releaseDate),
director: (movie.directors || []).map(buildPerson),
aggregateRating: buildRating(movie.aggregateRating, true),
review: (movie.reviews || []).map(buildReview)
};
}
// Summary page (ListItem.url to detail pages) or all-in-one page (full Movie in each ListItem).
function chooseCarouselApproach(source) {
return source.movies.every((m) => m.renderedOnThisPage) ? "all-in-one" : "summary";
}
function buildMovieCarouselSchema(source) {
const page = source.page;
if (!passesPageGates(page) || !Array.isArray(source.movies) || !source.movies.length) return null;
const approach = chooseCarouselApproach(source);
const host = new URL(page.url).hostname;
const entries = [];
for (const movie of source.movies) {
// Every item must be a Movie on the same domain as the list page.
if (!isAbsoluteUrl(movie.url) || new URL(movie.url).hostname !== host) continue;
if (approach === "summary") {
entries.push({ url: movie.url });
continue;
}
// All-in-one: each item needs its own URL fragment on this page.
if (!movie.url.startsWith(page.url + "#")) continue;
const node = buildMovieSchema(movie);
if (node) entries.push({ item: node });
}
if (!entries.length) return null;
return compact({
"@context": "https://schema.org",
"@type": "ItemList",
// Positions start at 1 and increase by 1 after filtering.
itemListElement: entries.map((entry, i) => ({ "@type": "ListItem", position: i + 1, ...entry }))
}) ?? null;
}
// Usage:
// const source = {
// "page": {
// "url": "https://www.example.com/lists/best-heist-films-2026",
// "canonicalUrl": "https://www.example.com/lists/best-heist-films-2026",
// "noindex": false,
// "contentVisible": true
// },
// "movies": [
// {
// "renderedOnThisPage": true,
// "url": "https://www.example.com/lists/best-heist-films-2026#the-vault-line",
// "name": "The Vault Line",
// "images": [
// {
// "url": "https://www.example.com/posters/the-vault-line.jpg",
// "width": 1200,
// "height": 1800
// }
// ],
// "releaseDate": "2026-02-13",
// "directors": [
// "Maya Ortiz"
// ],
// "aggregateRating": {
// "ratingValue": 4.4,
// "bestRating": 5,
// "worstRating": 1,
// "ratingCount": 1287
// },
// "reviews": [
// {
// "authorName": "Sam Patel",
// "datePublished": "2026-02-20",
// "rating": {
// "ratingValue": 5,
// "bestRating": 5,
// "worstRating": 1
// }
// }
// ]
// },
// {
// "renderedOnThisPage": true,
// "url": "https://www.example.com/lists/best-heist-films-2026#night-transfer",
// "name": "Night Transfer",
// "images": [
// {
// "url": "https://www.example.com/posters/night-transfer.png",
// "width": 1000,
// "height": 1500
// }
// ],
// "releaseDate": "2026-05-08",
// "directors": [
// "Daniel Okafor"
// ],
// "aggregateRating": {
// "ratingValue": 3.9,
// "bestRating": 5,
// "worstRating": 1,
// "ratingCount": 642
// },
// "reviews": [
// {
// "authorName": "Lena Fischer",
// "datePublished": "2026-05-15",
// "rating": {
// "ratingValue": 4,
// "bestRating": 5,
// "worstRating": 1
// }
// }
// ]
// }
// ]
// };
// const schema = buildMovieCarouselSchema(source);
# Shared helpers
import re
from datetime import date
from urllib.parse import urlparse
ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
ISO_DATETIME = re.compile(r"^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-](0\d|1[0-4]):[0-5]\d)$")
def is_absolute_url(value):
if not isinstance(value, str):
return False
try:
parts = urlparse(value)
except ValueError:
return False
return parts.scheme in ("http", "https") and bool(parts.hostname)
def to_iso_date(value):
if not isinstance(value, str) or not ISO_DATE.match(value):
return None
try:
date.fromisoformat(value)
except ValueError:
return None
return value
# Keeps the original timezone offset; rejects naive or unparseable values.
def to_iso_datetime(value):
match = ISO_DATETIME.match(value) if isinstance(value, str) else None
return value if match and to_iso_date(match.group(1)) else None
# Drops None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [kept for kept in (compact(item) for item in value) if kept is not None]
return items or None
if isinstance(value, dict):
out = {key: kept for key, kept in ((k, compact(v)) for k, v in value.items()) if kept is not None}
return out or None
return None if value is None or value == "" else value
# Page gates: indexable, self-canonical, marked-up content visible.
def passes_page_gates(page):
if not page or page.get("noindex") or page.get("robotsBlocked"):
return False
if not is_absolute_url(page.get("url")):
return False
if page.get("canonicalUrl") and page["canonicalUrl"] != page["url"]:
return False
return page.get("contentVisible") is True
# Movie helpers
IMAGE_FILE = re.compile(r"\.(jpe?g|png|gif)$", re.I)
def is_number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)
# 6:9 (2:3) poster ratio; Google can crop images that are close to it.
def is_poster_ratio(img):
if not img or not img.get("width") or not img.get("height"):
return False
return abs(img["width"] / img["height"] - 6 / 9) <= 0.05
def build_person(name):
return {"@type": "Person", "name": name} if name else None
def build_rating(r, is_aggregate):
if not r:
return None
best = r.get("bestRating", 5)
worst = r.get("worstRating", 1)
value = r.get("ratingValue")
if not is_number(value) or value < worst or value > best:
return None
count = r.get("ratingCount")
if is_aggregate and not (isinstance(count, int) and not isinstance(count, bool) and count > 0):
return None
return {
"@type": "AggregateRating" if is_aggregate else "Rating",
"ratingValue": value,
"bestRating": best,
"worstRating": worst,
"ratingCount": count if is_aggregate else None,
}
def build_review(review):
rating = build_rating(review and review.get("rating"), False)
if not rating or not review.get("authorName"):
return None # review needs author and rating
return {
"@type": "Review",
"reviewRating": rating,
"author": build_person(review["authorName"]),
"datePublished": to_iso_date(review.get("datePublished")),
}
# One Movie node. Required: name, image (crawlable .jpg/.png/.gif, about 6:9).
# Summary approach detail page: compact({"@context": "https://schema.org", **build_movie_schema(movie)})
def build_movie_schema(movie):
images = [img for img in (movie.get("images") or [])
if is_absolute_url(img.get("url")) and IMAGE_FILE.search(urlparse(img["url"]).path)
and is_poster_ratio(img)]
if not movie.get("name") or not images:
return None
return {
"@type": "Movie",
"url": movie.get("url") if is_absolute_url(movie.get("url")) else None,
"name": movie["name"],
"image": [img["url"] for img in images],
"dateCreated": to_iso_date(movie.get("releaseDate")),
"director": [build_person(n) for n in (movie.get("directors") or [])],
"aggregateRating": build_rating(movie.get("aggregateRating"), True),
"review": [build_review(r) for r in (movie.get("reviews") or [])],
}
# Summary page (ListItem.url to detail pages) or all-in-one page (full Movie in each ListItem).
def choose_carousel_approach(source):
return "all-in-one" if all(m.get("renderedOnThisPage") for m in source["movies"]) else "summary"
def build_movie_carousel_schema(source):
page = source.get("page")
movies = source.get("movies")
if not passes_page_gates(page) or not isinstance(movies, list) or not movies:
return None
approach = choose_carousel_approach(source)
host = urlparse(page["url"]).hostname
entries = []
for movie in movies:
# Every item must be a Movie on the same domain as the list page.
url = movie.get("url")
if not is_absolute_url(url) or urlparse(url).hostname != host:
continue
if approach == "summary":
entries.append({"url": url})
continue
# All-in-one: each item needs its own URL fragment on this page.
if not url.startswith(page["url"] + "#"):
continue
node = build_movie_schema(movie)
if node:
entries.append({"item": node})
if not entries:
return None
return compact({
"@context": "https://schema.org",
"@type": "ItemList",
# Positions start at 1 and increase by 1 after filtering.
"itemListElement": [{"@type": "ListItem", "position": i + 1, **entry} for i, entry in enumerate(entries)],
})
{
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"item": {
"@type": "Movie",
"url": "https://www.example.com/lists/best-heist-films-2026#the-vault-line",
"name": "The Vault Line",
"image": [
"https://www.example.com/posters/the-vault-line.jpg"
],
"dateCreated": "2026-02-13",
"director": [
{
"@type": "Person",
"name": "Maya Ortiz"
}
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.4,
"bestRating": 5,
"worstRating": 1,
"ratingCount": 1287
},
"review": [
{
"@type": "Review",
"reviewRating": {
"@type": "Rating",
"ratingValue": 5,
"bestRating": 5,
"worstRating": 1
},
"author": {
"@type": "Person",
"name": "Sam Patel"
},
"datePublished": "2026-02-20"
}
]
}
},
{
"@type": "ListItem",
"position": 2,
"item": {
"@type": "Movie",
"url": "https://www.example.com/lists/best-heist-films-2026#night-transfer",
"name": "Night Transfer",
"image": [
"https://www.example.com/posters/night-transfer.png"
],
"dateCreated": "2026-05-08",
"director": [
{
"@type": "Person",
"name": "Daniel Okafor"
}
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 3.9,
"bestRating": 5,
"worstRating": 1,
"ratingCount": 642
},
"review": [
{
"@type": "Review",
"reviewRating": {
"@type": "Rating",
"ratingValue": 4,
"bestRating": 5,
"worstRating": 1
},
"author": {
"@type": "Person",
"name": "Lena Fischer"
},
"datePublished": "2026-05-15"
}
]
}
}
]
}
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
- Prevents emitting empty, null, or stale fields that frequently trigger rich result warnings.
- Supports multiple page states (for example: no rating yet, no image yet, no offers yet) without duplicate templates.
- Lets engineering teams centralize schema policy checks and validation in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's Movie carousel documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Availability: The movie carousel is only available on mobile devices.
| Property | Expects | Notes | |
|---|---|---|---|
| image | Required | URL or ImageObject, can repeat | A high-resolution 6:9 image that represents the movie. Aspect ratios: 6x9. |
| name | Required | Text | The name of the movie. |
| aggregateRating | Recommended | AggregateRating | The average review score for the movie. |
| dateCreated | Recommended | Date or DateTime | The movie's release date. |
| director | Recommended | Person, can repeat | As a Person. |
| review | Recommended | Review, can repeat | A nested Review of the movie. |