Job Posting Schema Framework
Use conditional-logic schema generation to keep Job Posting markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralJob Posting documentationUse on the leaf page of a single open job posting that names the hiring company and where the employee works, or which countries a fully remote role accepts.
Key Implementation Documentation Highlights
- Required properties: title, description, datePosted, hiringOrganization, jobLocation. For fully remote jobs, applicantLocationRequirements can replace jobLocation. Every jobLocation.address needs addressCountry.
- Recommended properties: validThrough, baseSalary, employmentType, directApply, identifier, applicantLocationRequirements, jobLocationType, plus hiringOrganization.sameAs and hiringOrganization.logo. educationRequirements, experienceRequirements and experienceInPlaceOfEducation are beta.
- description is the full job in HTML (responsibilities, qualifications, skills, hours, education, experience) with <p>, <ul>, <li> or <br> breaks, and can't be the same as title. title is the job title only: no codes, addresses, dates, salaries or company names.
- datePosted and validThrough are ISO 8601. A past validThrough marks the job expired: expired jobs must lose active markup (past validThrough, 404/410, or remove JobPosting). Omit validThrough when the posting never expires.
- Fully remote jobs: jobLocationType TELECOMMUTE plus at least one country through applicantLocationRequirements (preferred) or a jobLocation addressCountry, and the description must state the eligible locations.
- employmentType: FULL_TIME, PART_TIME, CONTRACTOR, TEMPORARY, INTERN, VOLUNTEER, PER_DIEM or OTHER (case-sensitive, multiple allowed). baseSalary: 3-letter ISO 4217 currency, a value or a minValue to maxValue range, unitText HOUR, DAY, WEEK, MONTH or YEAR. Only employers can provide baseSalary.
- hiringOrganization is the company offering the job, not the hiring location; use "confidential" for anonymous hiring. A third-party logo needs a width to height ratio between 0.75 and 2.5.
- One JobPosting per page, on the most detailed leaf page, never on job listing pages. Google recommends the Indexing API over sitemaps for job URLs. Available in parts of Asia, Europe, MENA, all of Latin America, North America and Sub-Saharan Africa.
- Google expects content parity: every marked-up value must match what users can see on the page.
- Validate with the Rich Results Test and URL Inspection, then monitor the related Search Console report for errors and warnings after deployment.
Template Approach
const pageData = {
title: "Senior Technical SEO Analyst",
descriptionHtml: "<p>Own technical SEO for a 40,000 page ecommerce catalog.</p>",
datePosted: "2026-09-28",
company: "Example Retail Group",
city: "Waltham",
region: "MA",
postalCode: "02453"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "JobPosting",
"title": pageData.title,
"description": pageData.descriptionHtml,
"datePosted": pageData.datePosted,
"employmentType": "FULL_TIME",
"hiringOrganization": { "@type": "Organization", "name": pageData.company },
"jobLocation": {
"@type": "Place",
"address": {
"@type": "PostalAddress",
"addressLocality": pageData.city,
"addressRegion": pageData.region,
"postalCode": pageData.postalCode,
"addressCountry": "US"
}
}
};
{
"@context": "https://schema.org",
"@type": "JobPosting",
"title": "Senior Technical SEO Analyst",
"description": "<p>Own technical SEO for a 40,000 page ecommerce catalog.</p>",
"datePosted": "2026-09-28",
"employmentType": "FULL_TIME",
"hiringOrganization": {
"@type": "Organization",
"name": "Example Retail Group"
},
"jobLocation": {
"@type": "Place",
"address": {
"@type": "PostalAddress",
"addressLocality": "Waltham",
"addressRegion": "MA",
"postalCode": "02453",
"addressCountry": "US"
}
}
}
Conditional-logic Framework
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$/;
function isAbsoluteUrl(value) {
return typeof value === "string" && /^https?:\/\/[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$/i.test(value);
}
// Returns the ISO 8601 string unchanged (timezone offset kept), or null if unparseable.
function toIsoDateTime(value) {
if (typeof value !== "string") return null;
const text = value.trim();
const m = text.match(ISO_DATE_TIME);
if (!m) return null;
const [, y, mo, d, h = "00", mi = "00", s = "00"] = m;
const date = new Date(Date.UTC(+y, +mo - 1, +d));
if (date.getUTCMonth() !== +mo - 1 || date.getUTCDate() !== +d) return null;
if (+h > 23 || +mi > 59 || +s > 59) return null;
return text;
}
function toIsoDate(value) {
const iso = toIsoDateTime(value);
return iso ? iso.slice(0, 10) : null;
}
// Drops 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;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
function passesPageGates(source) {
if (source.indexable === false) return false;
if (source.canonicalUrl && source.url && source.canonicalUrl !== source.url) return false;
return source.contentVisible !== false;
}
const EMPLOYMENT_TYPES = ["FULL_TIME", "PART_TIME", "CONTRACTOR", "TEMPORARY", "INTERN", "VOLUNTEER", "PER_DIEM", "OTHER"];
const SALARY_UNITS = ["HOUR", "DAY", "WEEK", "MONTH", "YEAR"];
const CREDENTIAL_CATEGORIES = ["high school", "associate degree", "bachelor degree", "professional certificate", "postgraduate degree"];
function toCountryCode(value) {
const code = String(value || "").toUpperCase();
return /^[A-Z]{2}$/.test(code) ? code : null;
}
function buildJobPlace(location) {
const country = toCountryCode(location.country);
if (!country) return null; // addressCountry is required on every jobLocation
return {
"@type": "Place",
address: {
"@type": "PostalAddress",
streetAddress: location.street,
addressLocality: location.locality,
addressRegion: location.region,
postalCode: location.postalCode,
addressCountry: country
}
};
}
function buildBaseSalary(salary) {
if (!salary) return null;
const currency = String(salary.currency || "").toUpperCase();
const unitText = String(salary.unit || "").toUpperCase();
if (!/^[A-Z]{3}$/.test(currency) || !SALARY_UNITS.includes(unitText)) return null;
const { value, min, max } = salary;
if (value == null && (min == null || max == null || min > max)) return null; // a value, or a valid range
return {
"@type": "MonetaryAmount",
currency,
value: {
"@type": "QuantitativeValue",
value: value ?? null,
minValue: value == null ? min : null,
maxValue: value == null ? max : null,
unitText
}
};
}
function buildJobPostingSchema(source, now = new Date()) {
// (a) Page gates: indexable, self-canonical, a single job visible on this leaf page.
if (!passesPageGates(source)) return null;
// (b) Required: title, description (HTML, not the title), datePosted, hiringOrganization.
const datePosted = toIsoDateTime(source.datePosted);
const org = source.hiringOrganization || {};
if (!source.title || !source.descriptionHtml || !datePosted || !org.name) return null;
if (source.descriptionHtml.trim() === source.title.trim()) return null;
// Expired postings must not keep active markup.
const validThrough = toIsoDateTime(source.validThrough);
if (validThrough && Date.parse(validThrough) < now.getTime()) return null;
// Either-or: jobLocation, or applicantLocationRequirements for fully remote jobs.
const jobLocation = (source.locations || []).map(buildJobPlace).filter(Boolean);
const applicantCountries = (source.remoteEligibleCountries || []).map(toCountryCode).filter(Boolean);
const isRemote = source.fullyRemote === true;
if (isRemote && !applicantCountries.length && !jobLocation.length) return null; // TELECOMMUTE needs a country
if (!isRemote && !jobLocation.length) return null;
// (c) Required + recommended properties, only when the source has them.
const schema = {
"@context": "https://schema.org",
"@type": "JobPosting",
title: source.title,
description: source.descriptionHtml,
datePosted,
validThrough,
hiringOrganization: {
"@type": "Organization",
name: org.name,
sameAs: isAbsoluteUrl(org.url) ? org.url : null,
logo: isAbsoluteUrl(org.logo) ? org.logo : null
},
jobLocation,
jobLocationType: isRemote ? "TELECOMMUTE" : null,
applicantLocationRequirements: isRemote
? applicantCountries.map((code) => ({ "@type": "Country", name: code }))
: null,
// (d) Custom rules: enums, salary range, booleans.
employmentType: (source.employmentTypes || []).filter((t) => EMPLOYMENT_TYPES.includes(t)),
baseSalary: source.postedByEmployer ? buildBaseSalary(source.salary) : null, // only employers provide baseSalary
directApply: typeof source.directApply === "boolean" ? source.directApply : null,
identifier: source.requisitionId && {
"@type": "PropertyValue",
name: org.name,
value: String(source.requisitionId)
},
educationRequirements: CREDENTIAL_CATEGORIES.includes(source.education) ? {
"@type": "EducationalOccupationalCredential",
credentialCategory: source.education
} : null,
experienceRequirements: Number.isInteger(source.monthsOfExperience) && source.monthsOfExperience >= 0 ? {
"@type": "OccupationalExperienceRequirements",
monthsOfExperience: source.monthsOfExperience
} : null,
experienceInPlaceOfEducation: typeof source.experienceInPlaceOfEducation === "boolean"
? source.experienceInPlaceOfEducation
: null
};
// (e) Strip empty values.
return compact(schema);
}
// Usage:
// const source = {
// "url": "https://www.example.com/careers/senior-technical-seo-analyst",
// "canonicalUrl": "https://www.example.com/careers/senior-technical-seo-analyst",
// "indexable": true,
// "contentVisible": true,
// "title": "Senior Technical SEO Analyst",
// "descriptionHtml": "<p>Own technical SEO for a 40,000 page ecommerce catalog.</p><ul><li>Audit crawling, rendering and indexing</li><li>Ship structured data with engineering</li></ul><p>Hybrid schedule, three days a week in our Waltham office.</p>",
// "datePosted": "2026-09-28",
// "validThrough": "2026-12-31T23:59:00-05:00",
// "hiringOrganization": {
// "name": "Example Retail Group",
// "url": "https://www.example.com",
// "logo": "https://www.example.com/images/logo-square.png"
// },
// "locations": [
// {
// "street": "50 Main Street",
// "locality": "Waltham",
// "region": "MA",
// "postalCode": "02453",
// "country": "US"
// }
// ],
// "fullyRemote": false,
// "employmentTypes": [
// "FULL_TIME"
// ],
// "postedByEmployer": true,
// "salary": {
// "currency": "USD",
// "min": 110000,
// "max": 135000,
// "unit": "YEAR"
// },
// "directApply": true,
// "requisitionId": "SEO-2026-114",
// "education": "bachelor degree",
// "monthsOfExperience": 48,
// "experienceInPlaceOfEducation": true
// };
// const schema = buildJobPostingSchema(source); // null when a gate or required property fails
import re
from datetime import date, datetime, timezone
ISO_DATE_TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$")
ABSOLUTE_URL = re.compile(r"^https?://[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$", re.I)
def is_absolute_url(value):
return isinstance(value, str) and bool(ABSOLUTE_URL.match(value))
# Returns the ISO 8601 string unchanged (timezone offset kept), or None if unparseable.
def to_iso_date_time(value):
if not isinstance(value, str):
return None
text = value.strip()
m = ISO_DATE_TIME.match(text)
if not m:
return None
y, mo, d = int(m[1]), int(m[2]), int(m[3])
try:
date(y, mo, d)
except ValueError:
return None
if int(m[4] or 0) > 23 or int(m[5] or 0) > 59 or int(m[6] or 0) > 59:
return None
return text
def to_iso_date(value):
iso = to_iso_date_time(value)
return iso[:10] if iso else None
# Drops 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 = {k: c for k, c in ((k, compact(v)) for k, v in value.items()) if c is not None}
return out or None
return None if value is None or value == "" else value
def passes_page_gates(source):
if source.get("indexable") is False:
return False
if source.get("canonicalUrl") and source.get("url") and source["canonicalUrl"] != source["url"]:
return False
return source.get("contentVisible") is not False
EMPLOYMENT_TYPES = ["FULL_TIME", "PART_TIME", "CONTRACTOR", "TEMPORARY", "INTERN", "VOLUNTEER", "PER_DIEM", "OTHER"]
SALARY_UNITS = ["HOUR", "DAY", "WEEK", "MONTH", "YEAR"]
CREDENTIAL_CATEGORIES = ["high school", "associate degree", "bachelor degree", "professional certificate", "postgraduate degree"]
def to_country_code(value):
code = str(value or "").upper()
return code if re.fullmatch(r"[A-Z]{2}", code) else None
def parse_instant(value):
text = value.replace("Z", "+00:00") if "T" in value else value + "T00:00:00+00:00"
parsed = datetime.fromisoformat(text)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def build_job_place(location):
country = to_country_code(location.get("country"))
if not country:
return None # addressCountry is required on every jobLocation
return {
"@type": "Place",
"address": {
"@type": "PostalAddress",
"streetAddress": location.get("street"),
"addressLocality": location.get("locality"),
"addressRegion": location.get("region"),
"postalCode": location.get("postalCode"),
"addressCountry": country,
},
}
def build_base_salary(salary):
if not salary:
return None
currency = str(salary.get("currency") or "").upper()
unit_text = str(salary.get("unit") or "").upper()
if not re.fullmatch(r"[A-Z]{3}", currency) or unit_text not in SALARY_UNITS:
return None
value, low, high = salary.get("value"), salary.get("min"), salary.get("max")
if value is None and (low is None or high is None or low > high):
return None # a value, or a valid range
return {
"@type": "MonetaryAmount",
"currency": currency,
"value": {
"@type": "QuantitativeValue",
"value": value,
"minValue": low if value is None else None,
"maxValue": high if value is None else None,
"unitText": unit_text,
},
}
def build_job_posting_schema(source, now=None):
now = now or datetime.now(timezone.utc)
# (a) Page gates: indexable, self-canonical, a single job visible on this leaf page.
if not passes_page_gates(source):
return None
# (b) Required: title, description (HTML, not the title), datePosted, hiringOrganization.
date_posted = to_iso_date_time(source.get("datePosted"))
org = source.get("hiringOrganization") or {}
title, description = source.get("title"), source.get("descriptionHtml")
if not title or not description or not date_posted or not org.get("name"):
return None
if description.strip() == title.strip():
return None
# Expired postings must not keep active markup.
valid_through = to_iso_date_time(source.get("validThrough"))
if valid_through and parse_instant(valid_through) < now:
return None
# Either-or: jobLocation, or applicantLocationRequirements for fully remote jobs.
job_location = [p for p in (build_job_place(l) for l in source.get("locations") or []) if p]
applicant_countries = [c for c in (to_country_code(v) for v in source.get("remoteEligibleCountries") or []) if c]
is_remote = source.get("fullyRemote") is True
if is_remote and not applicant_countries and not job_location:
return None # TELECOMMUTE needs a country
if not is_remote and not job_location:
return None
months = source.get("monthsOfExperience")
# (c) Required + recommended properties, only when the source has them.
schema = {
"@context": "https://schema.org",
"@type": "JobPosting",
"title": title,
"description": description,
"datePosted": date_posted,
"validThrough": valid_through,
"hiringOrganization": {
"@type": "Organization",
"name": org["name"],
"sameAs": org.get("url") if is_absolute_url(org.get("url")) else None,
"logo": org.get("logo") if is_absolute_url(org.get("logo")) else None,
},
"jobLocation": job_location,
"jobLocationType": "TELECOMMUTE" if is_remote else None,
"applicantLocationRequirements": (
[{"@type": "Country", "name": code} for code in applicant_countries] if is_remote else None
),
# (d) Custom rules: enums, salary range, booleans.
"employmentType": [t for t in source.get("employmentTypes") or [] if t in EMPLOYMENT_TYPES],
"baseSalary": build_base_salary(source.get("salary")) if source.get("postedByEmployer") else None,
"directApply": source.get("directApply") if isinstance(source.get("directApply"), bool) else None,
"identifier": source.get("requisitionId") and {
"@type": "PropertyValue",
"name": org["name"],
"value": str(source["requisitionId"]),
},
"educationRequirements": {
"@type": "EducationalOccupationalCredential",
"credentialCategory": source.get("education"),
} if source.get("education") in CREDENTIAL_CATEGORIES else None,
"experienceRequirements": {
"@type": "OccupationalExperienceRequirements",
"monthsOfExperience": months,
} if isinstance(months, int) and not isinstance(months, bool) and months >= 0 else None,
"experienceInPlaceOfEducation": (
source.get("experienceInPlaceOfEducation")
if isinstance(source.get("experienceInPlaceOfEducation"), bool) else None
),
}
# (e) Strip empty values.
return compact(schema)
{
"@context": "https://schema.org",
"@type": "JobPosting",
"title": "Senior Technical SEO Analyst",
"description": "<p>Own technical SEO for a 40,000 page ecommerce catalog.</p><ul><li>Audit crawling, rendering and indexing</li><li>Ship structured data with engineering</li></ul><p>Hybrid schedule, three days a week in our Waltham office.</p>",
"datePosted": "2026-09-28",
"validThrough": "2026-12-31T23:59:00-05:00",
"hiringOrganization": {
"@type": "Organization",
"name": "Example Retail Group",
"sameAs": "https://www.example.com",
"logo": "https://www.example.com/images/logo-square.png"
},
"jobLocation": [
{
"@type": "Place",
"address": {
"@type": "PostalAddress",
"streetAddress": "50 Main Street",
"addressLocality": "Waltham",
"addressRegion": "MA",
"postalCode": "02453",
"addressCountry": "US"
}
}
],
"employmentType": [
"FULL_TIME"
],
"baseSalary": {
"@type": "MonetaryAmount",
"currency": "USD",
"value": {
"@type": "QuantitativeValue",
"minValue": 110000,
"maxValue": 135000,
"unitText": "YEAR"
}
},
"directApply": true,
"identifier": {
"@type": "PropertyValue",
"name": "Example Retail Group",
"value": "SEO-2026-114"
},
"educationRequirements": {
"@type": "EducationalOccupationalCredential",
"credentialCategory": "bachelor degree"
},
"experienceRequirements": {
"@type": "OccupationalExperienceRequirements",
"monthsOfExperience": 48
},
"experienceInPlaceOfEducation": true
}
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 Job posting documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Required, one of: Include jobLocation, or applicantLocationRequirements for fully remote jobs.
| Property | Expects | Notes | |
|---|---|---|---|
| datePosted | Required | Date or DateTime | The original date that employer posted the job in ISO 8601 format. |
| description | Required | Text | The full description of the job in HTML format. |
| hiringOrganization | Required | Organization | The organization offering the job position. |
| hiringOrganization.name | Recommended | Text | The name of the hiring company. |
| hiringOrganization.sameAs | Recommended | URL | The company's URL. |
| hiringOrganization.logo | Recommended | URL or ImageObject | The hiring organization's logo; width/height ratio must be between 0.75 and 2.5. |
| jobLocation | Required | Place, can repeat | The physical location(s) of the business where the employee will report to work. |
| jobLocation.address | Required | PostalAddress | The PostalAddress of the work location. |
| title | Required | Text | The title of the job (not the title of the posting) |
| applicantLocationRequirements | One of | AdministrativeArea, Country or State, can repeat | The geographic location(s) in which employees may be located to be eligible for the work from home job. |
| applicantLocationRequirements.name | Recommended | Text | The name of the country or state. |
| baseSalary | Recommended | MonetaryAmount | The actual base salary for the job, as provided by the employer. |
| baseSalary.currency | Recommended | Text | The ISO 4217 currency code. |
| baseSalary.value | Recommended | QuantitativeValue | The salary amount or range. |
| directApply | Recommended | Boolean | Whether the URL associated with this job posting enables direct application for the job. |
| employmentType | Recommended | Text, can repeat | Type of employment: FULL_TIME, PART_TIME, CONTRACTOR, TEMPORARY, INTERN, VOLUNTEER, PER_DIEM or OTHER. One of: FULL_TIME, PART_TIME, CONTRACTOR, TEMPORARY, INTERN, VOLUNTEER, and 2 more. |
| identifier | Recommended | PropertyValue | The hiring organization's unique identifier for the job. |
| identifier.name | Recommended | Text | The hiring organization name. |
| identifier.value | Recommended | Text | The unique job identifier. |
| jobLocationType | Recommended | Text | Set to TELECOMMUTE for jobs in which the employee may or must work remotely 100% of the time. One of: TELECOMMUTE. |
| validThrough | Recommended | DateTime or Date | The date when the job posting will expire in ISO 8601 format. |
| educationRequirements | Recommended | EducationalOccupationalCredential or Text, can repeat | The education required for the position. |
| educationRequirements.credentialCategory | Recommended | Text | One of high school, associate degree, bachelor degree, professional certificate, postgraduate degree. |
| experienceRequirements | Recommended | OccupationalExperienceRequirements or Text | The experience required for the position. |
| experienceRequirements.monthsOfExperience | Recommended | Number | The minimum number of months of experience required. |
| experienceInPlaceOfEducation | Recommended | Boolean | Whether the job posting will accept experience in place of its formal educational qualifications. |