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

> Google's JobPosting schema needs five properties, a real HTML description and an expiry plan. The field rules, a valid example and the policy errors.

Source: https://seofix.ai/blog/jobposting-structured-data  
Published: 2026-11-14  
Updated: 2026-11-02  
Author: SEOFix Team  
Category: Technical SEO  
Tags: Technical SEO, Guides  
Length: 2,098 words, 10 min read

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](https://developers.google.com/search/docs/appearance/structured-data/job-posting) (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](https://seofix.ai/blog/job-board-seo-technical-fixes). This post stays on the markup.

## A complete JobPosting example that validates

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

```json
{
  "@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 recommended | Recommended |
| --- | --- |
| Apply now for IT job -FRENCH speaker in Bucharest | Market 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":

```json
{
  "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:

```json
{
  "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.

## Recommended properties worth adding

| Property | What Google says | Notes |
| --- | --- | --- |
| `validThrough` | Expiry 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. |
| `baseSalary` | `MonetaryAmount` 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. |
| `employmentType` | `FULL_TIME`, `PART_TIME`, `CONTRACTOR`, `TEMPORARY`, `INTERN`, `VOLUNTEER`, `PER_DIEM`, `OTHER` | Case-sensitive. An array is allowed: `["FULL_TIME", "CONTRACTOR"]`. |
| `identifier` | "The hiring organization's unique identifier for the job." | A `PropertyValue` with `name` and `value`. |
| `directApply` | Whether the URL lets the user apply directly | Google'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`, `experienceInPlaceOfEducation` | Beta properties | `monthsOfExperience` 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](https://developers.google.com/search/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](https://seofix.ai/blog/google-indexing-api-job-postings-nextjs) 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 message | What caused it | Fix |
| --- | --- | --- |
| "Structured data policy violation - A list page should not include structured data for individual jobs" | `JobPosting` on a search, category or company page | Markup 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 page | Make 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 markup | One of the three expiry methods above |
| "Structured data policy violation - Can't submit application on job offer page." | No way to apply from the page | Add 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](https://developers.google.com/search/docs/appearance/structured-data/sd-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
@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](https://nextjs.org/docs/app/guides/json-ld) warns that `JSON.stringify` "does not sanitize malicious strings used in XSS injection" and suggests replacing `<` with `<`:

```typescript
<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:

```bash
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](https://seofix.ai/help/checks-on-page). 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.
