Fixing Date Format Errors in JSON-LD
Dates in structured data have to be machine-readable, and that means one format: ISO 8601, with the year first and, for a date and time, an offset from UTC.
Templates often print dates the way the page shows them to people, such as 28/09/2026 or 28 September 2026, or print nothing and leave an empty string or a 1970 date behind. Search engines then misread or ignore the value. This guide shows the correct formats, the exact formatter to use in PHP, WordPress, Liquid, JavaScript and Python, and how to catch dates that are well formed but still wrong.
What a valid date looks like
schema.org has two kinds of date value. A Date is a calendar day. A DateTime
is a moment in time. Both are written in ISO 8601, and properties such as datePublished
and startDate accept either.
| Value | Valid? | Notes |
|---|---|---|
2026-09-28 | Yes | A date with no time. Fine for priceValidUntil,
birth dates and all-day items. |
2026-09-28T14:30:00+01:00 | Yes | The clearest form: local time with its offset from UTC. |
2026-09-28T13:30:00Z | Yes | The same moment expressed in UTC. Z
means an offset of zero. |
2026-09-28T14:30:00.000+01:00 | Yes | Fractional seconds are allowed. |
2026-09-28T14:30:00 | Accepted, but ambiguous | No offset, so the reader has to guess the time zone. Google may assume one you did not intend. |
2026-09-28 14:30:00 | No | A space instead of T. This is
the raw database format and a very common leak. |
28/09/2026 or 09/28/2026 | No | Slashes, and no way to tell day from month. |
28 September 2026 | No | Readable by people, not a machine format. |
2026-9-8 | No | Months and days need two digits: 2026-09-08. |
1790596800 | No | A Unix timestamp is a number of seconds, not a date. |
cookTime, duration
and totalTime take ISO 8601 durations like PT45M, not dates. A date checker
that flags one of these is usually telling you a date was printed where a duration belongs.The right formatter in each language
Nearly every bad date comes from a template that reuses the display format. The fix is to call a separate formatter for the markup, leaving the visible date as it is.
| Environment | Prints the wrong thing | Use instead |
|---|---|---|
| PHP | date('d/m/Y', $ts) | date(DATE_ATOM, $ts) or $dt->format('c'),
both giving 2026-09-28T14:30:00+01:00. |
| WordPress | get_the_date() with no argument, which uses the site's display
setting | get_the_date( 'c' ) and get_the_modified_date( 'c' ), which
use the time zone set in Settings, General. |
| Shopify Liquid | {{ article.published_at | date: format: 'date' }} | {{ article.published_at | date: '%Y-%m-%dT%H:%M:%S%:z' }}.
Plain %z gives +0100 with no colon; check the output in view source. |
| JavaScript | toLocaleDateString() or toString() | toISOString(),
which always returns UTC with a Z. Correct, but the time shown will differ from local time
by the offset. |
| Python | strftime('%d %B %Y'), or isoformat() on a naive datetime | Make
the datetime time-zone aware first, for example with zoneinfo.ZoneInfo('Europe/London'),
then call isoformat(). |
| SQL straight to template | 2026-09-28 14:30:00 | Parse the value into a date object in your code and format it, or use the database's ISO formatting function. |
Two points are easy to miss. First, the offset must match when the date falls, not when the page is
built: a post published in January in the UK is +00:00, one published in July is +01:00.
Formatters that work from a named time zone such as Europe/London get this right automatically; a hard-coded
+01:00 string does not. Second, storing everything in UTC and outputting Z
is equally valid and avoids the question entirely, as long as the stored value really is UTC.
Empty, zero and placeholder dates
A date that was never set can surface in markup in several disguises. Each one tells you something about the code behind it.
"": the template printed an empty field. The value is not a date, and a checker flags it as invalid.1970-01-01T00:00:00+00:00or1970-01-01T01:00:00+01:00: an empty or null timestamp was treated as zero seconds, which is the start of 1970 in Unix time. Seeing 1970 anywhere in structured data means a missing value was formatted as if it existed.0000-00-00or0000-00-00 00:00:00: an empty MySQL date column printed raw. There is no month zero or day zero, so this is invalid.Invalid DateorNaN-NaN-NaN: JavaScript tried to format something it could not parse.- Today's date on every page: the template calls "now" when the real value is missing, so every post appears freshly published each time the cache is rebuilt.
The fix is the same in each case: test whether the source value exists before formatting it, and leave
the property out when it does not. A missing optional date is better than a false one. If the property
is required for a rich result, such as startDate on an Event, the event is not ready to
be marked up until a real date exists.
Dates that are valid but wrong
A value can be perfectly formatted and still make no sense. The JSON Checker and Google's tools look at date pairs and at dates that have run out.
| Property | Rule to check | Typical fault |
|---|---|---|
datePublished and dateModified | Modified should be the same as or later than published. | Imported posts where the import date became the published date but the original modified date was kept. |
startDate and endDate | End on or after start. | An evening event ending after midnight given the start day as its end day. |
priceValidUntil | Should be in the future while the price is offered. | A sale end date that passed months ago; Google may disregard the offer price in a rich result once it has expired. |
validThrough on JobPosting | Should not be in the past while the job is live. | A closing date that was never updated when the advert was extended. |
uploadDate on VideoObject | Should not be in the future. | A scheduled premiere date printed before the video is available. |
datePublished | Should match the date shown on the page. | Markup using the post's creation date while the page shows the publish date, or the other way round. |
A related trap is a dateModified that changes on every page build even when nothing was
edited. Some templates use the file's build time or the cache time. That does not break the markup, but
it misstates freshness, and Google's guidance is that the modified date should reflect meaningful changes
to the content.
A corrected block
This BlogPosting was previously printing "datePublished": "28 September 2026" from the
theme's display format and an empty dateModified. After switching both to an ISO formatter
and adding a check for the empty value, the output is:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Choosing Bulbs for a Shady Border",
"datePublished": "2026-09-28T08:15:00+01:00",
"dateModified": "2026-09-28T11:40:00+01:00",
"author": {
"@type": "Person",
"name": "Robin Ashdown",
"url": "https://www.example.co.uk/authors/robin-ashdown/"
},
"image": "https://www.example.co.uk/images/shady-border-bulbs-1200.jpg"
}
</script>
For a post that has never been edited, the template now either prints dateModified equal
to datePublished or leaves it out; both are acceptable. It no longer prints an empty string.
A quick pattern check
If you want to scan exported markup yourself, this regular expression matches a date or a full date-time with an optional offset. Anything in a date property that does not match deserves a look:
^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$
It checks shape only. It will accept 2026-02-30, so for anything automated, parse the
value with a real date library as well.
The fix, step by step
Step 1
Collect every date property per template
Run one page from each template through the JSON Checker and list every property holding
a date: datePublished, dateModified, startDate, endDate,
uploadDate, datePosted, validThrough, priceValidUntil
and any others. Note the exact value each prints and whether the checker flagged it. Include blocks from
plugins and apps as well as your own, since each source formats dates in its own way and may need its
own fix.
Step 2
Find the code that formats each date
Search the theme or template files for the property name, then follow the value back to the
function or filter that prints it. In WordPress look for get_the_date or a plugin setting;
in Shopify look for the Liquid date filter; in custom code look for string formatting of
a date object. If a plugin or app prints the date, check its settings for a format option before editing
its files.
Step 3
Switch to an ISO 8601 formatter with an offset
Replace the display format in the markup with an ISO formatter: DATE_ATOM
or 'c' in PHP and WordPress, %Y-%m-%dT%H:%M:%S%:z in Liquid, toISOString()
in JavaScript or isoformat() on a time-zone aware Python datetime. Base the offset on a
named time zone such as Europe/London rather than a fixed string, so winter and summer dates get the
correct offset automatically without anyone having to remember the clock change.
Step 4
Omit dates that have no real value
Wrap
each date in a check so the property is only printed when the source value exists and is not zero. This
stops empty strings, 0000-00-00 and 1970 dates reaching the page. Never fall back to the
current date, because it makes every page look newly published. If a required date such as an event start
is missing, hold back that type until the real date is known.
Step 5
Check the order and expiry of related dates
Compare each pair: modified on or after published, end on or after start. Look for dates
that have run out, such as a past priceValidUntil or validThrough, and either
update them from live data or remove them when there is no real end date. Confirm the published date
in the markup matches the date shown on the page, since a mismatch undermines trust in both.
Step 6
Retest and watch the reports
Re-run
the JSON Checker on each template and confirm every date now appears as YYYY-MM-DD or a
full date-time with an offset. Test one winter-dated and one summer-dated page to prove the offset changes
correctly. In Search Console, request indexing for key pages and use Validate fix on affected enhancement
reports, then check the same pages again after the next clock change in spring or autumn.
Keeping dates right
- Keep display and markup formats separate. Changing how dates look on the page should never change what goes into the structured data. Use two calls, not one shared variable.
- Store in UTC, format at the edge. Storing timestamps in UTC and converting only
when printing avoids most offset mistakes and makes
Zoutput trivially correct. - Test across the clock change. Keep one page dated in winter and one in summer in your template test list.
- Automate expiry. Build
priceValidUntilandvalidThroughfrom the same data that ends the sale or closes the job, so they cannot drift. - Check after migrations. Imports between platforms often reset published dates or carry them over as text. Re-run the date checks after any move.
For AI-generated markup, where the model itself chooses the date, the AI schema date guide covers the different set of mistakes that appear there.
1. Do I need to include a time in datePublished?
No. A plain date such as 2026-09-28 is valid ISO 8601 and schema.org accepts it. Adding a time with an offset makes the value more precise, which helps when a post is published close to midnight and the date could otherwise be read as the day before or after in another time zone. If your platform stores a full timestamp, output it with the offset; if it only stores a date, output the date alone.
2. Is Z better than +01:00 or +00:00?
Neither is better; they are different ways of writing the same moment. Z means the time is in UTC. An offset such as +01:00 means the time is local and that many hours ahead of UTC. Both are valid. Use Z when your code converts everything to UTC before printing, and a local offset when you print local times. What matters is that the time and the offset agree, so the moment described is the real one.
3. Why do my dates show as 1970?
A date of 1 January 1970 almost always means the source value was empty or null and the code formatted it anyway. Unix time counts seconds from the start of 1970, so a zero or missing timestamp becomes that date. Find the template or plugin field that supplies the date, check whether it is empty for the affected pages, and add a condition so the property is left out whenever there is no real value.
4. My Shopify dates end in +0100. Is that a problem?
Shopify date filters with the %z code print the offset without a colon, such as +0100. Strict ISO 8601 uses +01:00 in the extended format used for the rest of the value, and some validators flag the mixed form. Change the format string to use %:z, which prints the colon, and confirm the result in view source. Alternatively print the time in UTC with a Z, provided the value really is converted first.
5. Should dateModified change every time the page is rebuilt?
No. dateModified should reflect a meaningful change to the content, such as corrected facts or a rewritten section, not a cache refresh, a theme deployment or a change to a sidebar. Templates that use the build time or file time make every page look freshly updated, which misstates freshness. Base the value on the content record edit date, and ideally only update it for substantive edits rather than typo fixes.
6. What happens if priceValidUntil is in the past?
The markup still parses, but the offer describes a price that is, by its own statement, no longer valid. Google can ignore an expired price in a rich result, and the JSON Checker flags it as a logic problem. If the price has no planned end, leave priceValidUntil out entirely. If it is a sale price with an end date, generate the value from the same data that ends the sale, so the two always agree.
7. Do event dates need the local time zone of the venue?
Yes, for in-person events the times should be the local times at the venue with the offset that applies on that date. An event in London in July is written with +01:00; one in December with +00:00. For online events that people join from anywhere, choose the time zone you publish the event in and state its offset. Leaving the offset off means attendees in other time zones may see the wrong start time.
The JSON Checker parses every JSON-LD block on your page, validates the syntax, checks each type against schema.org, verifies required fields, detects conflicting types and runs the result against Google rich result criteria.
Open JSON-LD Fixes โ