Technical SEO

JobPosting schema: required fields, validThrough and the errors that cost you Google Jobs

The five properties Google requires on a job page, the recommended ones worth adding, how validThrough and expired jobs work, and the Search Console policy messages job sites run into most.

By SEOFix TeamPublished Updated
2,098 words · 10 min readMarkdown
JobPosting schema: required fields, validThrough and the errors that cost you Google Jobs

Google's JobPosting schema needs five properties before a page can appear in the job search experience: datePosted, description, hiringOrganization, jobLocation and title. A fully remote job can drop jobLocation if it has applicantLocationRequirements and jobLocationType: "TELECOMMUTE". On top of that, Google wants every closed job expired in one of three ways (a past validThrough, a 404/410, or no markup at all), and it treats markup that doesn't match the visible page as a policy violation, not a formatting slip.

Below: every required and recommended field with the rules Google attaches to it, a complete example that validates, server-side code for Laravel and Next.js, and the four Search Console policy messages job sites get most. All of Google's rules are quoted from the JobPosting documentation (last updated 8 September 2026, checked 2 November 2026).

For the wider job board picture (filter URLs, thin employer pages, sitemaps at 30,000 URLs) see Job board SEO: the technical fixes from a 37k-page job site. This post stays on the markup.

A complete JobPosting example that validates

Start from something correct and delete what you don't have:

{
  "@context": "https://schema.org",
  "@type": "JobPosting",
  "title": "Senior Accountant",
  "description": "<p>We are hiring a Senior Accountant to own month-end close for our UAE entities.</p><ul><li>5+ years in accounting, IFRS experience</li><li>Working hours: Monday to Friday, 9:00 to 18:00</li><li>Bachelor's degree in accounting or finance</li></ul>",
  "datePosted": "2026-10-28",
  "validThrough": "2026-12-15T23:59:59+04:00",
  "employmentType": "FULL_TIME",
  "hiringOrganization": {
    "@type": "Organization",
    "name": "Example Trading LLC",
    "sameAs": "https://www.example-trading.com",
    "logo": "https://www.example-trading.com/logo.png"
  },
  "jobLocation": {
    "@type": "Place",
    "address": {
      "@type": "PostalAddress",
      "streetAddress": "Office 1204, Business Bay Tower",
      "addressLocality": "Dubai",
      "addressRegion": "Dubai",
      "addressCountry": "AE"
    }
  },
  "baseSalary": {
    "@type": "MonetaryAmount",
    "currency": "AED",
    "value": {
      "@type": "QuantitativeValue",
      "minValue": 18000,
      "maxValue": 22000,
      "unitText": "MONTH"
    }
  },
  "identifier": {
    "@type": "PropertyValue",
    "name": "Example Trading LLC",
    "value": "FIN-2026-114"
  },
  "directApply": true
}

Put it in a <script type="application/ld+json"> block on the job's own page, and make sure every value in it is also visible on that page. The salary, the location and the closing date all have to appear in the HTML a job seeker reads. More on why below.

The five required properties, and the rules Google attaches to each

title

"The title of the job (not the title of the posting)." Google's own examples:

Not recommendedRecommended
Apply now for IT job -FRENCH speaker in BucharestMarket Specialist, French speaker
*** WAREHOUSE HIRING NOW!! ON A BUS ROUTE!! ***Shipping and Receiving Warehouse Associate

No job codes, addresses, dates, salaries or company names. Overusing ! and * "might cause your structured data to be considered as Spammy Structured Markup". And title and name "aren't interchangeable": a template that sets name and leaves title empty fails the requirement.

If you're a third-party job board, Google says not to rewrite employer titles to fit these rules, "as it can make the title unreadable. Instead, provide the title as you received it from the employer."

description

Google wants "the full description of the job in HTML format", covering "job responsibilities, qualifications, skills, working hours, education requirements, and experience requirements". Three rules in this section trip up job boards:

  • It "can't be the same as the title". A template that falls back to the title when the description field is empty produces exactly that.
  • It must be HTML with at least paragraph breaks (<br>, <p> or \n). The job feature recognises <p>, <ul> and <li>. Headings, <strong> and <em> are ignored for display but harmless.
  • A 200-character teaser in the markup while the page shows the full text is an incomplete description. The content policy says plainly: "We don't allow job postings with incomplete job descriptions."

datePosted

ISO 8601: "2026-10-28" or "2026-10-28T09:30:00+04:00". It's the date the employer first posted the job. Don't bump it every time you re-import the feed: a job that is "posted today" every day is the kind of mismatch the content policies cover.

hiringOrganization

"This must be the name of the company (for example, 'Starbucks, Inc'), and not the specific location that is hiring." Two options job boards need:

  • Anonymous employers: set "name": "confidential". Google documents that value for staffing agencies and employers hiring anonymously.
  • Logos: third-party job sites can supply hiringOrganization.logo. For JobPosting, "the image width and height ratio must be between 0.75 and 2.5". A wide horizontal wordmark at 4:1 doesn't qualify.

jobLocation

"The physical location(s) of the business where the employee will report to work", with addressCountry mandatory: "Note that you must include the addressCountry property." Include as many address properties as you have; Google says more of them makes a higher quality posting.

Several offices? Use an array. Google "will choose the best location to display based on the job seeker's query":

{
  "jobLocation": [
    { "@type": "Place", "address": { "@type": "PostalAddress", "addressLocality": "Dubai", "addressCountry": "AE" } },
    { "@type": "Place", "address": { "@type": "PostalAddress", "addressLocality": "Abu Dhabi", "addressCountry": "AE" } }
  ]
}

Don't put "Dubai / Abu Dhabi / Remote" into one addressLocality string. Google's troubleshooting section lists "Job location is missing or incorrect" for values it can't match to a real place.

Remote jobs: TELECOMMUTE and applicantLocationRequirements

Remote markup has the strictest wording in the doc:

  • jobLocationType: "TELECOMMUTE" is only for jobs where the employee "may or must work remotely 100% of the time". Google adds: "Don't mark up jobs that allow occasional work-from-home, jobs for which remote work is a negotiable benefit, or have other arrangements that are not 100% remote."
  • The description must also say the job is 100% remote.
  • You must name at least one country applicants can work from, with applicantLocationRequirements (preferred) or by falling back to the country in jobLocation when there's also a physical option. "If a remote job doesn't contain applicantLocationRequirements, Google shows the job to anyone in the country specified in jobLocation."

A fully remote role open to applicants in two countries:

{
  "jobLocationType": "TELECOMMUTE",
  "applicantLocationRequirements": [
    { "@type": "Country", "name": "United Arab Emirates" },
    { "@type": "Country", "name": "Saudi Arabia" }
  ]
}

A hybrid job (three office days a week) is not TELECOMMUTE. Mark it up with jobLocation only.

PropertyWhat Google saysNotes
validThroughExpiry in ISO 8601. "Required for job postings that have an expiration date."Omit it if the job never expires or you don't know. See the next section.
baseSalaryMonetaryAmount with a QuantitativeValue; unitText is one of HOUR, DAY, WEEK, MONTH, YEAR (case-sensitive)"The actual base salary for the job, as provided by the employer (not an estimate)." Use minValue/maxValue for ranges.
employmentTypeFULL_TIME, PART_TIME, CONTRACTOR, TEMPORARY, INTERN, VOLUNTEER, PER_DIEM, OTHERCase-sensitive. An array is allowed: ["FULL_TIME", "CONTRACTOR"].
identifier"The hiring organization's unique identifier for the job."A PropertyValue with name and value.
directApplyWhether the URL lets the user apply directlyGoogle's test: no clicking apply, filling a form, or logging in more than once. Google notes you "may not see any appearance or effect" yet.
educationRequirements, experienceRequirements, experienceInPlaceOfEducationBeta propertiesmonthsOfExperience for experience; credentialCategory such as "bachelor degree" for education. If experienceInPlaceOfEducation is true, both of the others are required.

Two traps in that table. baseSalary must be the employer's own figure: a job board that fills it from a salary estimator is putting an estimate where Google asked for the actual salary. And unitText: "Monthly" or "month" is not a valid value; it has to be MONTH.

Estimated salary markup is a separate thing. The Occupation type that powered Google's estimated salary feature was phased out in 2025, and its documentation was removed on 9 September 2025 (documentation updates). baseSalary on a JobPosting is unaffected. Our post on Google's retired structured data has the full list.

validThrough and expired jobs

Google's wording is a requirement, not a suggestion: "Jobs that are no longer open for applications must be expired in one of the following ways. Failure to take timely action on expired jobs may result in a manual action."

  1. "Ensure the validThrough property is populated and in the past."
  2. "Remove the page entirely (so that requesting it returns a 404 or 410 status code)."
  3. "Remove JobPosting structured data from the page."

And for jobs filled early: "If the job is filled before the expiration date occurs, remove the job posting." A future validThrough doesn't protect you once the role is filled.

Formatting: ISO 8601, with a time zone if you include a time. "2026-12-15" and "2026-12-15T23:59:59+04:00" are both fine. "15/12/2026" and "Dec 15" are not.

After you expire a job, tell Google. The doc recommends the Indexing API over sitemaps for job URLs "because the Indexing API prompts Googlebot to crawl your page sooner", while still keeping a sitemap for coverage. Our Indexing API guide for Next.js has working code for URL_UPDATED and URL_DELETED.

The policy errors Search Console sends

These come from the troubleshooting section of Google's doc, with the exact Search Console message for each. All four are manual-action territory: fix, then submit a reconsideration request.

Search Console messageWhat caused itFix
"Structured data policy violation - A list page should not include structured data for individual jobs"JobPosting on a search, category or company pageMarkup only on single-job pages. "Don't add structured data to pages intended to present a list of jobs."
"Structured data policy violation - Content on pages found to be different than structured data on the page"Title, salary or other values in the markup that aren't on the pageMake every marked-up value visible. Google's example: "the job salary is in the markup but not on the job page."
"Structured data policy violation - JobPosting structured data on expired job"Missing or future validThrough, page still live with markupOne of the three expiry methods above
"Structured data policy violation - Can't submit application on job offer page."No way to apply from the pageAdd an apply button, form or contact instructions

The content policies behind these also rule out jobs that need a login to read the description, postings that charge applicants, career-fair invitations posing as jobs, and resume collection when nobody is hiring. The general structured data policies add one more consequence worth knowing: a structured data manual action "means that a page loses eligibility for appearance as a rich result; it doesn't affect how the page ranks in Google web search."

Generate it on the server, with a JSON encoder

Hand-built JSON in a template breaks on the first job title with a quote mark, and a description containing </script> can end your script block early. Both problems go away when you serialise a data structure and escape <.

Laravel (Blade)

@php
$job = $posting; // Eloquent model
$isOpen = $job->status === 'open' && (! $job->closes_at || $job->closes_at->isFuture());

$jsonLd = $isOpen ? array_filter([
    '@context' => 'https://schema.org',
    '@type' => 'JobPosting',
    'title' => $job->title,
    'description' => $job->description_html,
    'datePosted' => $job->posted_at->toDateString(),
    'validThrough' => $job->closes_at?->toIso8601String(),
    'employmentType' => $job->employment_type, // FULL_TIME, PART_TIME...
    'hiringOrganization' => [
        '@type' => 'Organization',
        'name' => $job->company_confidential ? 'confidential' : $job->company->name,
    ],
    'jobLocation' => [
        '@type' => 'Place',
        'address' => [
            '@type' => 'PostalAddress',
            'addressLocality' => $job->city,
            'addressCountry' => $job->country_code, // required
        ],
    ],
]) : null;
@endphp

@if ($jsonLd)
<script type="application/ld+json">{!! json_encode($jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}</script>
@endif

array_filter drops validThrough when there's no closing date, which is what Google asks for. JSON_HEX_TAG encodes < and > as < and >, so HTML inside the description can't close the script tag.

Next.js (App Router)

The Next.js JSON-LD guide warns that JSON.stringify "does not sanitize malicious strings used in XSS injection" and suggests replacing < with <:

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>

Render it in the server component for the job page, so the markup is in the initial HTML.

Check it before Google does

Pull the JSON-LD out of a live job page and test the required fields with nothing more than curl and Python:

curl -s https://example.com/jobs/senior-accountant-dubai | python3 -c '
import json, re, sys
html = sys.stdin.read()
blocks = re.findall(r"<script[^>]*application/ld\+json[^>]*>(.*?)</script>", html, re.S)
for raw in blocks:
    data = json.loads(raw)
    for item in data if isinstance(data, list) else data.get("@graph", [data]):
        if item.get("@type") != "JobPosting":
            continue
        remote = item.get("jobLocationType") == "TELECOMMUTE" and item.get("applicantLocationRequirements")
        need = ["title", "description", "datePosted", "hiringOrganization"] + ([] if remote else ["jobLocation"])
        print("missing:", [k for k in need if not item.get(k)] or "none")
        print("validThrough:", item.get("validThrough", "not set"))
        print("description == title:", item.get("description") == item.get("title"))
'

Then, per template, run one open job, one closed job and one remote job through Google's Rich Results Test, and spot-check them with URL Inspection. Google recommends both. The Rich Results Test applies Google's syntax rules; it can't tell that a salary is missing from the visible page or that a job was filled yesterday. Those checks are on you.

On a whole site the markup tends to break by template, not by page. SEOFix reads the JSON-LD on every crawled page and reports STRUCTURED_DATA_INVALID (JSON that doesn't parse, or nodes without @context or @type) and STRUCTURED_DATA_MISSING_FIELD when a JobPosting lacks datePosted, description, hiringOrganization, title, or jobLocation (or applicantLocationRequirements for TELECOMMUTE jobs). The exact triggers are in the on-page checks reference. It doesn't judge expiry or content policy, so keep the Rich Results Test and URL Inspection in your routine.

FAQ

Is validThrough required for JobPosting?

Only for jobs that have an expiration date. Google lists it as recommended, with the note "This is required for job postings that have an expiration date." If a job never expires or you don't know when it will, leave it out, and expire the job another way (404/410 or removing the markup) when it closes.

Can I put JobPosting markup on a page that lists several jobs?

No. Google says the markup "must only be used on pages that contain a single job posting", and list pages with job markup trigger a specific policy violation. Give each job its own URL and mark that up.

Does JobPosting markup need to match what's on the page?

Yes, field by field. Google's example of a violation is a salary that appears in the markup but not on the job page. Everything you mark up should be readable in the rendered HTML.

What should a job board put in hiringOrganization when the employer is anonymous?

"name": "confidential". Google documents that value for staffing agencies and employers hiring anonymously.


Run a free SEOFix audit on your job site (first audit free, up to 500 pages) to find every job template with broken or incomplete JobPosting markup, grouped by URL pattern so you fix it once.

ShareXLinkedIn
JobPosting Schema: Required Fields, validThrough, Errors · SEOFix