Skip to content
DevPenh

Parse DateTime from JSON in Flutter and Dart

Parse API dates in Dart: ISO strings with and without offsets, FormatException: Invalid date format, Unix seconds vs milliseconds, and toIso8601String.

By Yorn Chanpenh · Last reviewed

JSON has no date type, so an API sends dates as strings such as "2026-10-28T07:00:00Z" or as numbers such as 1793170800. Turning them into a Dart DateTime is one line, but the wrong line gives a FormatException, a date in 1970 or a time that is off by your UTC offset. This guide shows what each format needs. Every example and error message was run on Dart 3.13.5 with the machine's zone set to Asia/Phnom_Penh (UTC+7); the package examples use intl 0.20.3 and json_serializable 6.15.2.

Quick lookup

The API sendsUse
"2026-10-28T07:00:00Z" or "…+07:00"DateTime.parse(s) (result is UTC)
"2026-10-28T14:00:00" (no offset)DateTime.parse(s), but it is read as local time; check what the API means
1793170800 (seconds)DateTime.fromMillisecondsSinceEpoch(s * 1000, isUtc: true)
1793170800000 (milliseconds)DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true)
"28/10/2026"DateFormat('dd/MM/yyyy').parseUtc(s) from package:intl
"Wed, 28 Oct 2026 07:00:00 GMT"HttpDate.parse(s) from dart:io

ISO 8601 strings: DateTime.parse

DateTime.parse reads ISO 8601 strings. When the string has Z or an offset, the result is a UTC DateTime for that exact instant:

DateTime.parse('2026-10-28T07:00:00Z');       // 2026-10-28 07:00:00.000Z
DateTime.parse('2026-10-28T14:00:00+07:00');  // 2026-10-28 07:00:00.000Z
DateTime.parse('2026-10-28 14:00:00');        // a space instead of T also works
DateTime.parse('2026-10-28');                 // midnight, local time

Fractions of a second are kept down to microseconds; extra digits are dropped, so 07:00:00.123456789Z becomes 07:00:00.123456Z.

Strings without an offset are local time

A string with no Z and no offset is read in the device's time zone. On a phone set to UTC+7:

final d = DateTime.parse('2026-10-28T14:00:00');
d.isUtc;    // false
d.toUtc();  // 2026-10-28 07:00:00.000Z

On a phone in another zone, the same string is a different instant. If the backend meant UTC but left out the Z, every user sees a time that is off by their own offset. Ask the backend to send Z or an offset. If you cannot change it and you know the value is UTC, add the Z yourself before parsing: DateTime.parse('${s}Z'). The local time to UTC guide explains offsets and zones in more detail.

FormatException: Invalid date format

DateTime.parse accepts only ISO 8601-style strings. Anything else throws:

DateTime.parse('28/10/2026');
// FormatException: Invalid date format
// 28/10/2026

DateTime.parse('Wed, 28 Oct 2026 07:00:00 GMT');
// FormatException: Invalid date format
// Wed, 28 Oct 2026 07:00:00 GMT

DateTime.parse('');
// FormatException: Invalid date format

The second line of the message is the string that failed, which tells you the real format. For a fixed pattern, use DateFormat from package:intl. Its parse returns local time and parseUtc returns UTC:

import 'package:intl/intl.dart';

DateFormat('dd/MM/yyyy').parse('28/10/2026');     // 2026-10-28 00:00:00.000
DateFormat('dd/MM/yyyy').parseUtc('28/10/2026');  // 2026-10-28 00:00:00.000Z

DateFormat('dd/MM/yyyy').parseStrict('2026-10-28');
// FormatException: Trying to read / from 2026-10-28 at 5

For the HTTP date format used in headers such as Date and Last-Modified, dart:io has HttpDate.parse, which returns UTC. dart:io is not available on Flutter web.

If a bad date should not crash the screen, use DateTime.tryParse, which returns null instead of throwing.

Unix timestamps: seconds vs milliseconds

DateTime.fromMillisecondsSinceEpoch expects milliseconds. Passing seconds does not throw; it gives a date in January 1970:

DateTime.fromMillisecondsSinceEpoch(1793170800, isUtc: true);
// 1970-01-21 18:06:10.800Z  (seconds read as milliseconds)

DateTime.fromMillisecondsSinceEpoch(1793170800 * 1000, isUtc: true);
// 2026-10-28 07:00:00.000Z

// Back to Unix seconds:
date.millisecondsSinceEpoch ~/ 1000;  // 1793170800

A current timestamp in seconds has 10 digits and one in milliseconds has 13. Pass isUtc: true so the value prints the same on every device; call toLocal() only for display. To check a number by hand, paste it into the timestamp converter, which detects seconds, milliseconds, microseconds and nanoseconds. More background: Unix timestamps explained.

Sending dates back: toIso8601String

toIso8601String() writes an offset only for UTC values. A local DateTime comes out with no offset at all, so the server cannot tell which instant you meant:

DateTime(2026, 10, 28, 14).toIso8601String();
// 2026-10-28T14:00:00.000     (local, no offset)

DateTime(2026, 10, 28, 14).toUtc().toIso8601String();
// 2026-10-28T07:00:00.000Z

Call toUtc() before sending, unless the API explicitly wants local wall-clock time.

DateTime fields in a model

A plain Dart model, with a required and an optional date, that sends UTC back to the server:

class Order {
  final int id;
  final DateTime createdAt;
  final DateTime? paidAt;

  const Order({required this.id, required this.createdAt, this.paidAt});

  factory Order.fromJson(Map<String, dynamic> json) => Order(
        id: json['id'] as int,
        createdAt: DateTime.parse(json['created_at'] as String),
        paidAt: json['paid_at'] == null
            ? null
            : DateTime.parse(json['paid_at'] as String),
      );

  Map<String, dynamic> toJson() => {
        'id': id,
        'created_at': createdAt.toUtc().toIso8601String(),
        'paid_at': paidAt?.toUtc().toIso8601String(),
      };
}

With {"id": 1, "created_at": "2026-10-28T14:00:00+07:00", "paid_at": null}, createdAt is 2026-10-28 07:00:00.000Z and toJson sends "2026-10-28T07:00:00.000Z".

With json_serializable, a DateTime or DateTime? field needs no extra code. The generated fromJson calls DateTime.parse and toJson calls toIso8601String(), so the offset rule above still applies: keep the values in UTC. For Unix timestamps, use an int field or your own converter.

The JSON to Dart generator leaves date strings as String, because it cannot tell which strings are dates or what the API means by them. Change those fields to DateTime using the patterns on this page.

Errors that are not about dates

If the value is not a string at all, the cast fails before DateTime.parse runs:

DateTime.parse(json['created_at'] as String);
// type 'int' is not a subtype of type 'String' in type cast   (a Unix timestamp)
// type 'Null' is not a subtype of type 'String' in type cast  (missing or null)

The first means the API sends a number: use the timestamp code above. The second means the key is missing or null: make the field nullable. The Flutter JSON type errors guide covers the other cast errors.

Comparing dates

== on DateTime is true only when both the instant and the UTC/local flag match. Use isAtSameMomentAs, isBefore and isAfter to compare instants:

final a = DateTime.parse('2026-10-28T07:00:00Z');
final b = a.toLocal();

a == b;                  // false
a.isAtSameMomentAs(b);   // true

DateTime knows only UTC and the device's local zone. To show a time in another named zone, such as the user's office, you need a time zone database package.

Part of the API debugging series. Start with How to Debug API Responses: A Practical Workflow.