Estimated Salary Schema Framework
Use conditional-logic schema generation to keep Estimated Salary markup aligned with real page state and avoid stale or invalid static templates.
- Google no longer shows Estimated salary 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.
- Occupation markup is still valid schema.org. It keeps salary figures machine readable for AI assistants, salary aggregators and other structured data consumers, but it earns no Google rich result.
- What to do instead: emit Occupation only when the figures are visible on the page, put pay for a specific opening in baseSalary on a live JobPosting (Job posting is still supported), and remove this feature 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. Occupation markup still describes salary insight pages that publish pay ranges for a specific occupation and location, for consumers other than Google Search.
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 required properties and no recommended properties for this feature any more. Any markup you keep is plain schema.org, not a Google requirement.
- Types the retired feature covered: Occupation and OccupationAggregationByEmployer. The second was Google specific and is not a schema.org type, so use Occupation for markup you keep.
- Framework rules (schema.org hygiene, not Google rules): estimatedSalary as one MonetaryAmountDistribution per pay period, currency as a three-letter ISO 4217 code, duration as an ISO 8601 period (P1Y, P1M, PT1H), and percentiles that are positive and never decrease from percentile10 to percentile90.
- General structured data policies still apply to anything you emit: the page is indexable, the values are visible on the page, and the markup is a true representation of the content.
Template Approach
const pageData = {
occupation: "Registered Nurse",
city: "Boston, MA",
currency: "USD",
p10: 78000,
median: 101200,
p90: 128300
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Occupation",
"name": pageData.occupation,
"occupationLocation": [{ "@type": "City", "name": pageData.city }],
"estimatedSalary": [{
"@type": "MonetaryAmountDistribution",
"name": "base",
"currency": pageData.currency,
"duration": "P1Y",
"percentile10": pageData.p10,
"median": pageData.median,
"percentile90": pageData.p90
}]
};
{
"@context": "https://schema.org",
"@type": "Occupation",
"name": "Registered Nurse",
"occupationLocation": [
{
"@type": "City",
"name": "Boston, MA"
}
],
"estimatedSalary": [
{
"@type": "MonetaryAmountDistribution",
"name": "base",
"currency": "USD",
"duration": "P1Y",
"percentile10": 78000,
"median": 101200,
"percentile90": 128300
}
]
}
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);
// Pay period to ISO 8601 duration (schema.org QuantitativeValueDistribution.duration).
const PAY_PERIODS = { hour: "PT1H", day: "P1D", week: "P1W", month: "P1M", year: "P1Y" };
const PERCENTILES = [
["p10", "percentile10"],
["p25", "percentile25"],
["median", "median"],
["p75", "percentile75"],
["p90", "percentile90"]
];
const PLACE_TYPES = { city: "City", state: "State", country: "Country" };
// One MonetaryAmountDistribution per pay period; null when the figures are unusable.
function buildDistribution(range, currency) {
const duration = PAY_PERIODS[range.period];
if (!duration || !isCurrencyCode(currency)) return null;
const dist = { "@type": "MonetaryAmountDistribution", name: range.label, currency, duration };
let previous = 0;
for (const [key, prop] of PERCENTILES) {
const value = toNumber(range[key]);
if (value === null) continue;
if (value <= 0 || value < previous) return null; // positive and non-decreasing
dist[prop] = value;
previous = value;
}
return PERCENTILES.some(([, prop]) => prop in dist) ? dist : null;
}
// Retired rich result: Google lists no required properties. This is schema.org
// Occupation markup for other consumers, gated on a minimum useful core.
function buildOccupationSchema(source) {
if (!passesPageGates(source.page)) return null;
if (!source.name) return null;
const estimatedSalary = (source.salaryRanges || [])
.map((range) => buildDistribution(range, source.currency))
.filter(Boolean);
if (estimatedSalary.length === 0) return null;
const locations = (source.locations || [])
.filter((loc) => PLACE_TYPES[loc.kind] && loc.name)
.map((loc) => ({ "@type": PLACE_TYPES[loc.kind], name: loc.name }));
const schema = {
"@context": "https://schema.org",
"@type": "Occupation",
name: source.name,
description: source.description,
url: source.page.url,
occupationalCategory: source.occupationalCategory,
occupationLocation: locations,
estimatedSalary,
skills: source.skills
};
return compact(schema);
}
// Usage:
// const source = {
// "page": {
// "url": "https://www.example.com/salaries/registered-nurse/boston-ma",
// "canonicalUrl": "https://www.example.com/salaries/registered-nurse/boston-ma",
// "contentVisible": true
// },
// "name": "Registered Nurse",
// "description": "Registered nurse pay in Boston, MA, based on 1,240 salary reports submitted between January and September 2026.",
// "occupationalCategory": "29-1141.00",
// "locations": [
// {
// "kind": "city",
// "name": "Boston, MA"
// }
// ],
// "currency": "USD",
// "salaryRanges": [
// {
// "label": "base",
// "period": "year",
// "p10": 78000,
// "p25": 89500,
// "median": 101200,
// "p75": 114800,
// "p90": 128300
// },
// {
// "label": "base",
// "period": "hour",
// "p10": 37.5,
// "p25": 43,
// "median": 48.65,
// "p75": 55.2,
// "p90": 61.7
// }
// ],
// "skills": "Patient assessment, medication administration, electronic health records"
// };
// const schema = buildOccupationSchema(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
# Pay period to ISO 8601 duration (schema.org QuantitativeValueDistribution.duration).
PAY_PERIODS = {"hour": "PT1H", "day": "P1D", "week": "P1W", "month": "P1M", "year": "P1Y"}
PERCENTILES = [
("p10", "percentile10"),
("p25", "percentile25"),
("median", "median"),
("p75", "percentile75"),
("p90", "percentile90"),
]
PLACE_TYPES = {"city": "City", "state": "State", "country": "Country"}
# One MonetaryAmountDistribution per pay period; None when the figures are unusable.
def build_distribution(range_, currency):
duration = PAY_PERIODS.get(range_.get("period"))
if not duration or not is_currency_code(currency):
return None
dist = {"@type": "MonetaryAmountDistribution", "name": range_.get("label"), "currency": currency, "duration": duration}
previous = 0
for key, prop in PERCENTILES:
value = to_number(range_.get(key))
if value is None:
continue
if value <= 0 or value < previous: # positive and non-decreasing
return None
dist[prop] = value
previous = value
return dist if any(prop in dist for _, prop in PERCENTILES) else None
# Retired rich result: Google lists no required properties. This is schema.org
# Occupation markup for other consumers, gated on a minimum useful core.
def build_occupation_schema(source):
if not passes_page_gates(source.get("page")):
return None
if not source.get("name"):
return None
estimated_salary = [
dist for dist in (build_distribution(r, source.get("currency")) for r in source.get("salaryRanges") or [])
if dist
]
if not estimated_salary:
return None
locations = [
{"@type": PLACE_TYPES[loc["kind"]], "name": loc["name"]}
for loc in source.get("locations") or []
if loc.get("kind") in PLACE_TYPES and loc.get("name")
]
schema = {
"@context": "https://schema.org",
"@type": "Occupation",
"name": source["name"],
"description": source.get("description"),
"url": source["page"]["url"],
"occupationalCategory": source.get("occupationalCategory"),
"occupationLocation": locations,
"estimatedSalary": estimated_salary,
"skills": source.get("skills"),
}
return compact(schema)
# Usage: json_ld = build_occupation_schema(source) # same source dict as the JS example; None means emit nothing
{
"@context": "https://schema.org",
"@type": "Occupation",
"name": "Registered Nurse",
"description": "Registered nurse pay in Boston, MA, based on 1,240 salary reports submitted between January and September 2026.",
"url": "https://www.example.com/salaries/registered-nurse/boston-ma",
"occupationalCategory": "29-1141.00",
"occupationLocation": [
{
"@type": "City",
"name": "Boston, MA"
}
],
"estimatedSalary": [
{
"@type": "MonetaryAmountDistribution",
"name": "base",
"currency": "USD",
"duration": "P1Y",
"percentile10": 78000,
"percentile25": 89500,
"median": 101200,
"percentile75": 114800,
"percentile90": 128300
},
{
"@type": "MonetaryAmountDistribution",
"name": "base",
"currency": "USD",
"duration": "PT1H",
"percentile10": 37.5,
"percentile25": 43,
"median": 48.65,
"percentile75": 55.2,
"percentile90": 61.7
}
],
"skills": "Patient assessment, medication administration, electronic health records"
}
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
- Stops emitting Occupation markup on pages where the salary table is empty, hidden or noindexed, instead of shipping a hard-coded template everywhere.
- Drops a pay period whose percentiles are missing, non-positive or out of order, rather than publishing a distribution that contradicts itself.
- Keeps hourly and yearly figures as separate distributions with the correct ISO 8601 duration, so consumers never mix units.
- Since the rich result is retired, the gate is about accuracy for other consumers, not Google eligibility; nothing here should be tracked in Search Console.
Property Reference
Google retired the Estimated Salary 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