Vehicle Listing Schema Framework
Use conditional-logic schema generation to keep Vehicle Listing markup aligned with real page state and avoid stale or invalid static templates.
- Google no longer shows Vehicle listing rich results. The phase-out was announced on Jun 12, 2025 (Simplifying the search results page), and on Sep 9, 2025 Google removed the documentation, the Search Console report and Rich Results Test support.
- Car and Vehicle markup is still valid schema.org and still describes inventory for AI assistants, marketplaces and other consumers. Because Vehicle is a Product subtype, validators check it against the Product snippet and merchant listing rules, so it must stay valid as a Product.
- What to do instead: keep one Car per vehicle detail page with an Offer that matches the visible price, satisfy the Product rules below, and remove Vehicle listing from Search Console monitoring.
When To Use It
Google Search Central RetiredDocumentation changelogRetired feature. The old documentation URL now redirects to the Search Central updates changelog. Car and Vehicle markup still describes dealer inventory pages, one vehicle per page, for consumers other than this Google feature.
Key Implementation Documentation Highlights
- Status: retired. Phase-out announced Jun 12, 2025; documentation, Search Console reporting, Rich Results Test support and the Search appearance filter removed Sep 9, 2025. Google lists no Vehicle listing required or recommended properties any more.
- Types the retired feature covered: Car and Vehicle. Both are schema.org Product subtypes, so Product rules apply to whatever you emit.
- Product snippet: required name, plus one of offers, review or aggregateRating. Merchant listing: required name, image and offers, with offers.price greater than zero and priceCurrency as a three-letter ISO 4217 code.
- Value rules: availability and itemCondition use schema.org URLs (InStock, NewCondition, UsedCondition, RefurbishedCondition), priceValidUntil must not be in the past, and sku must not contain whitespace.
- Only mark up reviews and ratings that are genuinely about the vehicle and visible on the page; never borrow dealer reviews for a single car.
Template Approach
const pageData = {
title: "Used 2023 Honda CR-V EX-L AWD",
photo: "https://www.example.com/photos/h24817-front.jpg",
make: "Honda",
model: "CR-V",
year: "2023",
price: 31995
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Car",
"name": pageData.title,
"image": pageData.photo,
"brand": { "@type": "Brand", "name": pageData.make },
"model": pageData.model,
"vehicleModelDate": pageData.year,
"offers": {
"@type": "Offer",
"price": pageData.price,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock"
}
};
{
"@context": "https://schema.org",
"@type": "Car",
"name": "Used 2023 Honda CR-V EX-L AWD",
"image": "https://www.example.com/photos/h24817-front.jpg",
"brand": {
"@type": "Brand",
"name": "Honda"
},
"model": "CR-V",
"vehicleModelDate": "2023",
"offers": {
"@type": "Offer",
"price": 31995,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock"
}
}
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;
}
function toNumber(value) {
if (value === null || value === undefined || value === "" || typeof value === "boolean") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
const isCurrencyCode = (value) => typeof value === "string" && /^[A-Z]{3}$/.test(value);
const isVin = (value) => typeof value === "string" && /^[A-HJ-NPR-Z0-9]{17}$/.test(value);
const VEHICLE_TYPES = { car: "Car", motorcycle: "Motorcycle", bus: "BusOrCoach", ebike: "MotorizedBicycle" };
const CONDITIONS = {
new: "https://schema.org/NewCondition",
used: "https://schema.org/UsedCondition",
refurbished: "https://schema.org/RefurbishedCondition"
};
const AVAILABILITY = {
in_stock: "https://schema.org/InStock",
sold_out: "https://schema.org/SoldOut",
pre_order: "https://schema.org/PreOrder"
};
const ODOMETER_UNITS = { mi: "SMI", km: "KMT" };
const DRIVE_WHEELS = {
awd: "https://schema.org/AllWheelDriveConfiguration",
fwd: "https://schema.org/FrontWheelDriveConfiguration",
rwd: "https://schema.org/RearWheelDriveConfiguration",
"4wd": "https://schema.org/FourWheelDriveConfiguration"
};
// Vehicle and its subtypes are Product subtypes in schema.org.
function chooseVehicleType(source) {
return VEHICLE_TYPES[source.vehicleClass] || "Vehicle";
}
// Vehicle listing rich results are retired (Sep 9, 2025). The output is still a
// Product subtype, so it is gated on the Product rules validators apply to it:
// name, absolute image, and an Offer with price > 0 in an ISO 4217 currency.
function buildVehicleSchema(source) {
if (!passesPageGates(source.page)) return null;
const images = (source.images || []).filter(isAbsoluteUrl);
const offer = source.offer || {};
const price = toNumber(offer.price);
if (!source.name || images.length === 0) return null;
if (price === null || price <= 0 || !isCurrencyCode(offer.currency)) return null;
const condition = CONDITIONS[source.condition] || null;
const mileage = toNumber(source.mileage && source.mileage.value);
const mileageUnit = source.mileage && ODOMETER_UNITS[source.mileage.unit];
const validUntil = toIsoDate(offer.validUntil);
const schema = {
"@context": "https://schema.org",
"@type": chooseVehicleType(source),
name: source.name,
description: source.description,
image: images,
url: source.page.url,
category: source.category,
sku: typeof source.stockNumber === "string" && !/\s/.test(source.stockNumber) ? source.stockNumber : null,
vehicleIdentificationNumber: isVin(source.vin) ? source.vin : null,
brand: source.make ? { "@type": "Brand", name: source.make } : null,
model: source.model,
vehicleModelDate: /^\d{4}$/.test(String(source.modelYear || "")) ? String(source.modelYear) : null,
bodyType: source.bodyType,
color: source.color,
fuelType: source.fuelType,
vehicleTransmission: source.transmission,
driveWheelConfiguration: DRIVE_WHEELS[source.drivetrain] || null,
numberOfDoors: toNumber(source.doors),
mileageFromOdometer: mileage !== null && mileage >= 0 && mileageUnit
? { "@type": "QuantitativeValue", value: mileage, unitCode: mileageUnit }
: null,
itemCondition: condition,
offers: {
"@type": "Offer",
price,
priceCurrency: offer.currency,
availability: AVAILABILITY[offer.availability] || null,
itemCondition: condition,
url: source.page.url,
priceValidUntil: validUntil && validUntil >= new Date().toISOString().slice(0, 10) ? validUntil : null,
seller: offer.dealerName ? { "@type": "AutoDealer", name: offer.dealerName } : null
}
};
return compact(schema);
}
// Usage:
// const source = {
// "page": {
// "url": "https://www.example.com/inventory/2023-honda-cr-v-ex-l-awd-h24817",
// "canonicalUrl": "https://www.example.com/inventory/2023-honda-cr-v-ex-l-awd-h24817",
// "contentVisible": true
// },
// "vehicleClass": "car",
// "name": "Used 2023 Honda CR-V EX-L AWD",
// "description": "One-owner 2023 Honda CR-V EX-L with all-wheel drive, leather seats and a clean history report.",
// "images": [
// "https://www.example.com/photos/h24817-front-16x9.jpg",
// "https://www.example.com/photos/h24817-side-4x3.jpg",
// "https://www.example.com/photos/h24817-interior-1x1.jpg"
// ],
// "category": "Vehicles & Parts > Vehicles > Motor Vehicles > Cars, Trucks & Vans",
// "stockNumber": "H24817",
// "vin": "7FARS4H75PE012345",
// "make": "Honda",
// "model": "CR-V",
// "modelYear": 2023,
// "bodyType": "SUV",
// "color": "Meteorite Gray Metallic",
// "fuelType": "Gasoline",
// "transmission": "Automatic (CVT)",
// "drivetrain": "awd",
// "doors": 4,
// "mileage": {
// "value": 21450,
// "unit": "mi"
// },
// "condition": "used",
// "offer": {
// "price": 31995,
// "currency": "USD",
// "availability": "in_stock",
// "validUntil": "2026-11-30",
// "dealerName": "Example Honda of Waltham"
// }
// };
// const schema = buildVehicleSchema(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
def to_number(value):
if value is None or value == "" or isinstance(value, bool):
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num != num or num in (float("inf"), float("-inf")):
return None
return int(num) if num.is_integer() else num
def is_currency_code(value):
return isinstance(value, str) and re.fullmatch(r"[A-Z]{3}", value) is not None
def is_vin(value):
return isinstance(value, str) and re.fullmatch(r"[A-HJ-NPR-Z0-9]{17}", value) is not None
VEHICLE_TYPES = {"car": "Car", "motorcycle": "Motorcycle", "bus": "BusOrCoach", "ebike": "MotorizedBicycle"}
CONDITIONS = {
"new": "https://schema.org/NewCondition",
"used": "https://schema.org/UsedCondition",
"refurbished": "https://schema.org/RefurbishedCondition",
}
AVAILABILITY = {
"in_stock": "https://schema.org/InStock",
"sold_out": "https://schema.org/SoldOut",
"pre_order": "https://schema.org/PreOrder",
}
ODOMETER_UNITS = {"mi": "SMI", "km": "KMT"}
DRIVE_WHEELS = {
"awd": "https://schema.org/AllWheelDriveConfiguration",
"fwd": "https://schema.org/FrontWheelDriveConfiguration",
"rwd": "https://schema.org/RearWheelDriveConfiguration",
"4wd": "https://schema.org/FourWheelDriveConfiguration",
}
# Vehicle and its subtypes are Product subtypes in schema.org.
def choose_vehicle_type(source):
return VEHICLE_TYPES.get(source.get("vehicleClass"), "Vehicle")
# Vehicle listing rich results are retired (Sep 9, 2025). The output is still a
# Product subtype, so it is gated on the Product rules validators apply to it:
# name, absolute image, and an Offer with price > 0 in an ISO 4217 currency.
def build_vehicle_schema(source):
page = source.get("page")
if not passes_page_gates(page):
return None
images = [url for url in source.get("images") or [] if is_absolute_url(url)]
offer = source.get("offer") or {}
price = to_number(offer.get("price"))
if not source.get("name") or not images:
return None
if price is None or price <= 0 or not is_currency_code(offer.get("currency")):
return None
condition = CONDITIONS.get(source.get("condition"))
mileage_src = source.get("mileage") or {}
mileage = to_number(mileage_src.get("value"))
mileage_unit = ODOMETER_UNITS.get(mileage_src.get("unit"))
valid_until = to_iso_date(offer.get("validUntil"))
stock = source.get("stockNumber")
year = str(source.get("modelYear") or "")
schema = {
"@context": "https://schema.org",
"@type": choose_vehicle_type(source),
"name": source["name"],
"description": source.get("description"),
"image": images,
"url": page["url"],
"category": source.get("category"),
"sku": stock if isinstance(stock, str) and not re.search(r"\s", stock) else None,
"vehicleIdentificationNumber": source["vin"] if is_vin(source.get("vin")) else None,
"brand": {"@type": "Brand", "name": source["make"]} if source.get("make") else None,
"model": source.get("model"),
"vehicleModelDate": year if re.fullmatch(r"\d{4}", year) else None,
"bodyType": source.get("bodyType"),
"color": source.get("color"),
"fuelType": source.get("fuelType"),
"vehicleTransmission": source.get("transmission"),
"driveWheelConfiguration": DRIVE_WHEELS.get(source.get("drivetrain")),
"numberOfDoors": to_number(source.get("doors")),
"mileageFromOdometer": {"@type": "QuantitativeValue", "value": mileage, "unitCode": mileage_unit}
if mileage is not None and mileage >= 0 and mileage_unit else None,
"itemCondition": condition,
"offers": {
"@type": "Offer",
"price": price,
"priceCurrency": offer["currency"],
"availability": AVAILABILITY.get(offer.get("availability")),
"itemCondition": condition,
"url": page["url"],
"priceValidUntil": valid_until if valid_until and valid_until >= date.today().isoformat() else None,
"seller": {"@type": "AutoDealer", "name": offer["dealerName"]} if offer.get("dealerName") else None,
},
}
return compact(schema)
# Usage: json_ld = build_vehicle_schema(source) # same source dict as the JS example; None means emit nothing
{
"@context": "https://schema.org",
"@type": "Car",
"name": "Used 2023 Honda CR-V EX-L AWD",
"description": "One-owner 2023 Honda CR-V EX-L with all-wheel drive, leather seats and a clean history report.",
"image": [
"https://www.example.com/photos/h24817-front-16x9.jpg",
"https://www.example.com/photos/h24817-side-4x3.jpg",
"https://www.example.com/photos/h24817-interior-1x1.jpg"
],
"url": "https://www.example.com/inventory/2023-honda-cr-v-ex-l-awd-h24817",
"category": "Vehicles & Parts > Vehicles > Motor Vehicles > Cars, Trucks & Vans",
"sku": "H24817",
"vehicleIdentificationNumber": "7FARS4H75PE012345",
"brand": {
"@type": "Brand",
"name": "Honda"
},
"model": "CR-V",
"vehicleModelDate": "2023",
"bodyType": "SUV",
"color": "Meteorite Gray Metallic",
"fuelType": "Gasoline",
"vehicleTransmission": "Automatic (CVT)",
"driveWheelConfiguration": "https://schema.org/AllWheelDriveConfiguration",
"numberOfDoors": 4,
"mileageFromOdometer": {
"@type": "QuantitativeValue",
"value": 21450,
"unitCode": "SMI"
},
"itemCondition": "https://schema.org/UsedCondition",
"offers": {
"@type": "Offer",
"price": 31995,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/UsedCondition",
"url": "https://www.example.com/inventory/2023-honda-cr-v-ex-l-awd-h24817",
"priceValidUntil": "2026-11-30",
"seller": {
"@type": "AutoDealer",
"name": "Example Honda of Waltham"
}
}
}
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
- Suppresses markup for vehicles that sold, lost their photos, or show a call-for-price placeholder, instead of publishing a zero or stale price.
- Maps internal codes (used, awd, mi) to schema.org enumerations and units (UsedCondition, AllWheelDriveConfiguration, SMI) in one place.
- Validates the VIN pattern and stock number before emitting them, and drops a priceValidUntil that has already passed.
- Keeps the Car valid under Product rules, which is the only Google validation that still applies to it.
Property Reference
Google retired the Vehicle Listing rich result, so its documentation no longer lists required or recommended properties. The framework above is still valid schema.org markup and still describes the page to search engines and AI assistants.
Schema.org types used