JSON Date Format Errors: ISO 8601, Timestamps, and Timezone Pitfalls Explained
Quick answer
💡JSON has no native date type — dates serialize as strings or Unix timestamp numbers. JSON.parse does not convert ISO strings back to Date objects automatically. Use a reviver function to restore dates during parsing, always serialize with new Date().toISOString() for UTC strings, and validate date fields with ToolDock JSON Validator before debugging your application logic.
Error symptoms
- ✕
Invalid Date when calling new Date(parsedValue) on a field that looks like a date string - ✕
Date comparison returns incorrect results because the string was never converted to a Date object - ✕
API accepts a date but stores it 24 hours off because of a missing timezone offset - ✕
JSON.stringify(record) serializes a Date field as an ISO string on send, but JSON.parse returns a plain string on receive - ✕
createdAt.toLocaleDateString is not a function — createdAt is a string, not a Date object - ✕
Dates parsed identically in production but differ by hours in local development due to timezone differences
Common causes
- •JSON.parse does not automatically convert ISO 8601 strings to Date objects — they remain strings
- •The date string uses a non-ISO locale format like MM/DD/YYYY which some JavaScript engines parse inconsistently
- •Unix timestamps are stored as milliseconds in JavaScript but as seconds in most other languages and APIs
- •A date-only string like '2026-05-05' is parsed as UTC midnight in modern JavaScript but as local midnight in some older implementations
- •The API sends dates with a timezone offset like +02:00 and the receiver converts them to UTC before storage, losing the original offset
- •Moment.js parses dates in local time by default, while date-fns parseISO and dayjs always parse ISO strings as UTC
When it happens
- •Displaying a booking or appointment time in the user's local timezone when the API stores dates in UTC
- •Comparing two dates from different API endpoints where one uses ISO strings and the other uses Unix timestamps
- •Deserializing database records where PostgreSQL stores timestamp with time zone but the serializer omits the offset
- •Syncing records between services where one uses millisecond timestamps and another uses second-precision Unix timestamps
- •Storing a user-submitted date from a form that sends a locale-format string the backend cannot parse
Examples and fixes
A record from the API includes a createdAt field as an ISO string. The developer assumes JSON.parse converts it to a Date object automatically and then calls date methods on it.
JSON.parse returning a string instead of a Date object
❌ Wrong
const rawJson = '{"profileName":"Alice","createdAt":"2026-05-05T09:30:00.000Z","score":42}';
const record = JSON.parse(rawJson);
// createdAt is still a string — JSON.parse does not convert ISO strings
console.log(typeof record.createdAt); // 'string'
const age = Date.now() - record.createdAt;
// NaN — cannot subtract a string from a number
console.log(record.createdAt.toLocaleDateString());
// TypeError: record.createdAt.toLocaleDateString is not a function✅ Fixed
const rawJson = '{"profileName":"Alice","createdAt":"2026-05-05T09:30:00.000Z","score":42}';
// Use a reviver to convert date fields during parsing
const record = JSON.parse(rawJson, (key, value) => {
if (key.endsWith('At') && typeof value === 'string') {
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? value : parsed;
}
return value;
});
console.log(record.createdAt instanceof Date); // true
const ageMs = Date.now() - record.createdAt.getTime();
console.log(record.createdAt.toLocaleDateString()); // works correctlyJSON.parse does not inspect string values for date-like patterns — it treats all JSON strings as JavaScript strings regardless of their content. A reviver function is the standard mechanism for converting specific fields during parsing. The reviver in the fixed example matches any key ending with the suffix 'At', which is a common naming convention for timestamp fields. It attempts to construct a Date from the string value and validates the result with isNaN on getTime() before substituting the Date object, so malformed strings are left as strings rather than silently becoming Invalid Date instances.
A form submits a date in MM/DD/YYYY format. The server stores it and returns it in the same format. The client tries to create a Date object and compare dates, but the behavior is inconsistent across environments.
Locale-format date strings causing Invalid Date
❌ Wrong
const appointmentData = {
patientName: 'Robert Chen',
appointmentDate: '05/15/2026', // MM/DD/YYYY locale format
reminderSent: false
};
// Parsing locale format — behavior varies by JavaScript engine
const apptDate = new Date(appointmentData.appointmentDate);
console.log(apptDate.toISOString());
// In V8: works but treats as local time (timezone-dependent)
// In other environments: may return Invalid Date✅ Fixed
const appointmentData = {
patientName: 'Robert Chen',
appointmentDate: '2026-05-15', // ISO 8601 date format
reminderSent: false
};
// ISO date-only strings: parse as UTC midnight in modern JavaScript
// Use date-fns parseISO for explicit, consistent behavior
import { parseISO, formatISO } from 'date-fns';
const apptDate = parseISO(appointmentData.appointmentDate);
console.log(isNaN(apptDate.getTime())); // false — valid Date
console.log(formatISO(apptDate)); // '2026-05-15T00:00:00.000Z'The ECMAScript specification does not define parsing behavior for non-ISO date strings, making MM/DD/YYYY, DD/MM/YYYY, and other locale formats engine-dependent. A date that parses correctly in Chrome on a developer's machine may return Invalid Date in a different environment. ISO 8601 is the only date format the specification requires all engines to parse consistently. Libraries like date-fns parseISO and dayjs provide an additional layer of safety by parsing the ISO string explicitly rather than delegating to the engine's heuristic parser.
Why JSON treats dates as opaque strings
The JSON specification defines exactly six value types: string, number, boolean, null, object, and array. Date is not among them. This was a deliberate design decision — JSON is a language-neutral data interchange format, and different programming languages represent dates in fundamentally incompatible ways. JavaScript has a Date object. Python has datetime. Go has time.Time. Java has Instant, LocalDate, ZonedDateTime, and others. There is no single representation that maps cleanly across all of these.
The consequence is that whenever you serialize a JavaScript Date with JSON.stringify, the Date object's toJSON method is called, which returns an ISO 8601 string in UTC: '2026-05-05T09:30:00.000Z'. The JSON text that gets written to the wire is a quoted string — it looks like a date to a human reader, but it is just a string as far as the JSON format is concerned. There is no metadata in the JSON text that says 'this string is a date'.
When JSON.parse reads that string back, it produces the JavaScript string '2026-05-05T09:30:00.000Z' — not a Date object. The parser has no way to know that the string was originally serialized from a Date unless you tell it explicitly using a reviver function. This asymmetry between serialization and deserialization is the root cause of most JSON date errors. Developers who see JSON.stringify handling Date objects automatically assume JSON.parse is equally smart about converting them back, but it is not.
Unix timestamps as numbers avoid this asymmetry because a JavaScript number survives the JSON round-trip exactly. Math.floor(Date.now() / 1000) serializes as a plain JSON number and comes back from JSON.parse as the same number. However, timestamps introduce their own problems: the precision difference between millisecond and second timestamps causes off-by-1000 errors, and a raw integer does not communicate its unit of measurement or timezone context to anyone reading the JSON.
For APIs that need to exchange dates reliably across languages and systems, ISO 8601 with an explicit UTC offset — always the Z suffix for UTC — is the recommended convention. It is unambiguous about the timezone, parseable by libraries in every major language, and human-readable enough to debug from log output without a reference.
Spotting invalid Date objects after JSON.parse
The most reliable way to detect that a date field was not converted to a Date object is to check typeof parsedRecord.createdAt after calling JSON.parse. If the result is 'string', the field was not converted. If it is 'object' and parsedRecord.createdAt instanceof Date is true, then a reviver ran and converted it. If typeof is 'number', the field was stored as a Unix timestamp.
For diagnosing Invalid Date specifically, call isNaN(new Date(fieldValue).getTime()). This pattern is the standard validity check. new Date() on an invalid string returns a Date object whose internal timestamp is NaN, and getTime() exposes that NaN. The comparison isNaN(result.getTime()) returns true for any malformed date string and false for any valid one. Do not use toString() to check for validity — an Invalid Date object's toString() method returns the string 'Invalid Date', but comparing strings is fragile and locale-dependent.
For debugging in Node.js, a quick way to identify all string fields that look like dates is to run Object.entries(parsedRecord).filter(([k, v]) => typeof v === 'string' && !isNaN(Date.parse(v))). This iterates the record's properties and finds string values that Date.parse can interpret. These are your unconverted date fields. Note that Date.parse is a less strict parser than the reviver approach — it may successfully parse strings that are not ISO 8601 — but it is useful for discovery.
At the API boundary, the best diagnostic is to capture the raw response body as text before calling .json() and inspect it directly. Fetch provides response.text() and response.json() — call text() on one request to see the raw JSON string, then check whether date fields arrive as strings, numbers, or something else. This eliminates any uncertainty about whether the conversion happened before or after your code touched the data.
Reviver functions for automatic date restoration
A JSON reviver function is the standard way to convert date strings back to Date objects during parsing. The reviver receives two arguments: the key name and the value at that key. It must return the value to use in the parsed result — either the original value or a transformed version. When the reviver is passed to JSON.parse as the second argument, it is called for every key-value pair in the JSON, including nested ones.
The simplest reviver for ISO date strings checks whether the value is a string that matches a date-like pattern: (key, value) => { if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { const d = new Date(value); return isNaN(d.getTime()) ? value : d; } return value; }. The regex matches any string starting with four digits, a hyphen, two digits, another hyphen, two more digits, and T — which covers ISO 8601 datetime strings. The validation check prevents silently converting a string that looks like a date but produces Invalid Date.
A more targeted reviver uses field name conventions instead of pattern matching. If your codebase consistently uses the At suffix for timestamp fields and the Date suffix for date-only fields, your reviver can match on key names: if (key.endsWith('At') || key.endsWith('Date')). This approach is faster because it skips the regex check for most fields and is more explicit about which fields are expected to be dates.
For larger applications, consider using a date library instead of the native Date constructor for both parsing and formatting. date-fns parseISO provides strict ISO 8601 parsing and throws a clear error on invalid input rather than silently producing an Invalid Date. dayjs is similar and has a smaller footprint. luxon's DateTime.fromISO is the most explicit option and handles timezone-aware parsing correctly. All three are actively maintained, unlike Moment.js which is in maintenance mode and should not be used in new code.
ISO 8601 versus Unix timestamps in APIs
The choice between ISO 8601 strings and Unix timestamps in API design has real engineering tradeoffs. ISO 8601 strings like '2026-05-05T09:30:00.000Z' are human-readable in logs and debugging tools, carry timezone information explicitly in the string itself, and are parseable by standard library functions in every major language without custom code. Unix timestamps as numbers are compact, sort lexicographically as strings, and require no parsing — they can be compared directly with arithmetic operators.
The critical difference is precision and timezone. Unix timestamps in most non-JavaScript systems are second-precision integers. JavaScript's Date.now() returns milliseconds. If your backend stores a Unix timestamp as a second-precision integer and your JavaScript client calls new Date(timestamp), you get a date in January 1970 if the timestamp was 1746441000 seconds — correctly representing the date — but if the backend sends 1746441000000 expecting the client to divide by 1000, the client ends up at the year 57,311. Always document the precision unit in your API schema.
Timezone handling is the second critical difference. An ISO 8601 string with the Z suffix or a numeric offset is unambiguous — it encodes both a point in time and the timezone context that produced the representation. A Unix timestamp represents only a point in time with no timezone information. When converting a Unix timestamp to a human-readable date, the displayed date depends entirely on which timezone the conversion uses. Different servers and clients in different timezones will display different calendar dates for the same timestamp if the display timezone is not specified explicitly.
Database-side timezone handling introduces additional complexity. PostgreSQL's TIMESTAMP WITH TIME ZONE stores the value in UTC internally and converts to the session timezone on output. MySQL's DATETIME has no timezone concept at all — what you store is what you get, with no conversion. If your serialization code reads from MySQL DATETIME fields and writes them to JSON without normalizing to UTC, you get timezone-naïve strings that look like UTC but are actually in whatever timezone the MySQL server or application server was configured to use. Always normalize to UTC before serialization.
Timezone offsets corrupting date round-trips
A timezone round-trip bug is one of the hardest JSON date errors to diagnose because the date values look almost correct — they are off by a consistent number of hours rather than being completely wrong or Invalid Date. The bug typically manifests as appointments appearing on the wrong day, scheduled jobs running at the wrong hour, or date comparisons being slightly off when records cross a midnight boundary in a non-UTC timezone.
The most common cause is a backend that reads a timezone-aware date from the database, converts it to the local server timezone during serialization, and writes the resulting string without a timezone offset suffix. The client receives '2026-05-05T09:30:00' — no Z, no offset — and interprets it as local time in the client's timezone. In New York (UTC-5), this represents 14:30 UTC. In Berlin (UTC+2), the same string represents 07:30 UTC. The two clients see the same JSON string but interpret it as two different moments in time.
The fix is to always include a timezone offset in every datetime string. UTC strings should include the Z suffix: '2026-05-05T09:30:00.000Z'. Strings in a specific timezone should include the numeric offset: '2026-05-05T11:30:00.000+02:00'. Never transmit a datetime string without a timezone designator unless both sender and receiver have an out-of-band agreement that all times are in UTC — and even then, adding Z is the safer default.
Another common mistake is using JavaScript's new Date('2026-05-05') — a date-only ISO string without a time component. The ECMAScript specification states that date-only strings are parsed as UTC midnight. However, new Date('05/05/2026') — a locale-format date string — is parsed as local midnight in most implementations. Developers who switch between the two formats unknowingly introduce a timezone offset equal to their local UTC offset for records created during certain parts of the day.
Standardizing date serialization across services
Establishing a single date serialization convention across all services in a system prevents the entire class of timezone and format mismatch errors. The recommended convention is to serialize all datetime values as ISO 8601 strings with the Z (UTC) suffix and all date-only values as ISO 8601 date strings without a time component. Document this convention in your API specification and enforce it with response validation middleware on the server side.
For JavaScript and TypeScript applications, centralize date serialization and deserialization in a single module. Create utility functions like serializeDate(date: Date): string that always returns new Date(date).toISOString() and deserializeDate(value: string): Date that uses date-fns parseISO and validates the result. Import only from this module throughout the application. This ensures that a decision to add milliseconds to timestamps or to switch the date library affects only one file.
For TypeScript, create a branded type or opaque type for serialized date strings to prevent raw strings from being used where dates are expected. A type like type IsoDateString = string & { readonly brand: unique symbol } prevents passing an arbitrary string to a function that expects a validated ISO date string. Combine this with a validator function that constructs the branded type only after confirming parseISO returns a valid Date.
In JSON Schema, date fields should use type: 'string' with format: 'date-time' for full datetime fields and format: 'date' for date-only fields. JSON Schema validators like ajv enforce the format keyword and reject strings that do not conform to RFC 3339, which is a subset of ISO 8601. Enabling format validation in your schema middleware catches malformed dates before they reach application code, preventing Invalid Date instances from propagating into business logic and producing incorrect results that are difficult to trace back to their origin.
Quick fix checklist
- ✓Use a JSON.parse reviver to convert ISO date strings to Date objects — JSON.parse alone returns strings for all fields
- ✓Always serialize dates with new Date().toISOString() to produce UTC strings with the Z suffix
- ✓Check isNaN(new Date(fieldValue).getTime()) to validate date fields rather than comparing toString() output
- ✓Include an explicit timezone offset in every datetime string — never transmit datetimes without Z or a numeric offset
- ✓Use date-fns parseISO or dayjs instead of new Date() for strict, engine-independent ISO 8601 parsing
- ✓Document timestamp precision in your API spec — milliseconds in JavaScript, seconds in most other systems
- ✓Add format: 'date-time' to JSON Schema definitions for datetime fields to enable format validation at the API boundary
- ✓Normalize MySQL DATETIME values to UTC before serializing — MySQL DATETIME has no timezone and will reflect server timezone
Related guides
Frequently asked questions
Why does JSON.parse not convert ISO date strings back to Date objects?
JSON has no date type — all dates are strings in JSON. JSON.parse faithfully produces a JavaScript string for every JSON string value, regardless of its content. It has no way to know which strings should be dates without being told. Use a reviver function as the second argument to JSON.parse to convert specific fields or pattern-matching strings to Date objects during parsing.
What is the correct date format for JSON APIs?
Use ISO 8601 with the Z suffix for UTC datetimes: '2026-05-05T09:30:00.000Z'. For date-only fields with no time component, use the ISO date format: '2026-05-05'. Never use locale-specific formats like MM/DD/YYYY in JSON APIs — these formats are not specified in the JSON standard and produce inconsistent results across JavaScript engines and other language parsers.
How do I check whether a date field is valid after JSON.parse?
Use isNaN(new Date(fieldValue).getTime()). Construct a Date from the field value and call getTime() on it. If getTime() returns NaN, the string was not a valid date and new Date() produced an Invalid Date object. This check works for both string fields and number fields. Do not rely on comparing the Date's toString() output to the string 'Invalid Date' — that approach is fragile across locales.
What is the difference between Unix timestamp in seconds and milliseconds?
JavaScript's Date.now() returns milliseconds since the Unix epoch. Most other systems — Unix APIs, Python's time.time(), Go's time.Now().Unix(), and many databases — use seconds. Passing a second-precision timestamp to JavaScript's new Date() without multiplying by 1000 produces a date in January 1970. Always document the unit in your API spec and multiply or divide at the serialization boundary, not inside business logic.
Why do dates appear off by hours between environments?
A datetime string without a timezone designator is interpreted in the local timezone of each environment. A string like '2026-05-05T09:30:00' without a Z or offset means 09:30 in whatever timezone the JavaScript engine's host is configured to use. On a UTC server it represents 09:30 UTC. On a developer machine in EST it represents 14:30 UTC. Always include Z or a numeric offset to remove this ambiguity.
Should I use Moment.js for JSON date handling?
No. Moment.js is in long-term maintenance mode and the project itself recommends migrating to alternatives. Use date-fns for tree-shakeable utility functions, dayjs for a Moment-compatible API with a smaller footprint, or luxon for full IANA timezone database support. All three handle ISO 8601 parsing correctly and are actively maintained. For new projects, date-fns or dayjs are the most common choices.
How do I handle dates in JSON Schema?
Define date fields with type: 'string' and format: 'date-time' for full datetime values or format: 'date' for date-only values. These formats are defined in JSON Schema's specification based on RFC 3339. Validators like ajv with the formats option enabled reject strings that do not conform to the format. This catches invalid or missing timezone designators at the API boundary before they propagate into application code.
Why is the date stored in the database one day off from what I sent?
The most common cause is a date-only ISO string being parsed as UTC midnight and then stored in a database or displayed using the server's local timezone. For example, '2026-05-05' parsed as UTC midnight at 00:00:00Z appears as May 4th at 20:00 in EST, which a database configured to US/Eastern might store as May 4th. Always normalize date values to UTC before storage and use TIMESTAMP WITH TIME ZONE in PostgreSQL.
How do I serialize dates correctly with JSON.stringify?
JSON.stringify automatically calls the toJSON method on Date objects, which returns an ISO 8601 string with the Z suffix representing UTC. You do not need to call toISOString() manually before stringifying. However, if you have already converted dates to strings elsewhere in your code, verify that the strings include the Z suffix. Passing a Date to JSON.stringify is the safest approach because toJSON is guaranteed to produce a well-formed UTC ISO string.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-05.