Skip to content
Blog

JSON Serialization in Flutter with json_serializable

json_serializable 6.14.1 generates type-safe to/from JSON code, enums, custom converters, and even JSON Schema — no hand-written maps.

Published on • October 7, 2026

AI Assistant

Hand-writing json['key'] as String casts is the most common source of runtime surprises in Flutter apps. One missing field, one null that shouldn’t be null, and the crash happens three layers away from the API call that caused it.

json_serializable eliminates that class of bugs by generating the conversion code at build time. The current release is 6.14.1, published by the Google verified publisher, with 3.9k likes and 3.64M downloads.

The Setup

Three dependencies and one build command:

dependencies:
  json_annotation: ^4.12.0

dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.14.1
dart run build_runner build

Annotate your class, declare the generated part file, and connect the generated functions:

import 'package:json_annotation/json_annotation.dart';

part 'person.g.dart';

@JsonSerializable(createJsonSchema: true)
class Person {
  final String firstName, lastName;
  final DateTime? dateOfBirth;

  Person({required this.firstName, required this.lastName, this.dateOfBirth});

  factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);
  Map<String, dynamic> toJson() => _$PersonToJson(this);

  static const jsonSchema = _$PersonJsonSchema;
}

Building creates person.g.dart:

Person _$PersonFromJson(Map<String, dynamic> json) => Person(
  firstName: json['firstName'] as String,
  lastName: json['lastName'] as String,
  dateOfBirth: json['dateOfBirth'] == null
      ? null
      : DateTime.parse(json['dateOfBirth'] as String),
);

Map<String, dynamic> _$PersonToJson(Person instance) => <String, dynamic>{
  'firstName': instance.firstName,
  'lastName': instance.lastName,
  'dateOfBirth': instance.dateOfBirth?.toIso8601String(),
};

The generator only looks for a fromJson factory and a toJson method — it doesn’t care how they were produced. That becomes important for custom types.

Three Levels of Configuration

You can tune codegen at three layers, each taking precedence over the next:

  1. @JsonKey on a field — per-field overrides like renaming, defaults, custom converters.
  2. @JsonSerializable on the class — per-class settings (createJsonSchema, fieldRename, includeIfNull, etc.).
  3. build.yaml — project-wide defaults for every @JsonSerializable class.

Every JsonSerializable field is configurable in build.yaml, which is the right place for house style:

targets:
  $default:
    builders:
      json_serializable:
        options:
          field_rename: snake
          include_if_null: false
          explicit_to_json: true

Useful defaults from the docs: any_map: false, checked: false, create_factory: true, create_to_json: true, date_time_utc: false, disallow_unrecognized_keys: false, generic_argument_factories: false, ignore_unannotated: false.

Enums Without Boilerplate

Enums get their own annotations. @JsonEnum sets the rename policy for all values; @JsonValue maps individual values:

@JsonEnum(fieldRename: FieldRename.kebab)
enum Status {
  @JsonValue('in-progress')
  inProgress,
  done,
}

For enhanced enums, valueField pulls the serialized form from a field:

@JsonEnum(valueField: 'code')
enum StatusCodeEnhanced {
  success(200),
  movedPermanently(301),
  found(302),
  internalServerError(500);

  const StatusCodeEnhanced(this.code);
  final int code;
}

Supported Types

Out of the box: BigInt, bool, DateTime, double, Duration, Enum, int, Iterable, List, Map, num, Object, Record, Set, String, Uri — and collections of those. Map keys can be BigInt, DateTime, Enum, int, Object, String, or Uri.

Custom Types: Three Escape Hatches

1. Own the type. If you control it, just give it fromJson/toJson — the generator finds them without any annotation on the type itself:

class Sample2 {
  factory Sample2.fromJson(int value) => Sample2(value);
  final int value;
  int toJson() => value;
}

2. Per-field functions. JsonKey(toJson:, fromJson:) with top-level or static functions:

@JsonKey(toJson: _toJson, fromJson: _fromJson)
final DateTime value;

static int _toJson(DateTime value) => value.millisecondsSinceEpoch;
static DateTime _fromJson(int ms) => DateTime.fromMillisecondsSinceEpoch(ms);

3. JsonConverter. Best when the same conversion applies to many fields — and crucially, it works inside collections:

class EpochDateTimeConverter implements JsonConverter<DateTime, int> {
  const EpochDateTimeConverter();

  @override
  DateTime fromJson(int json) => DateTime.fromMillisecondsSinceEpoch(json);

  @override
  int toJson(DateTime object) => object.millisecondsSinceEpoch;
}

JSON Schema Generation

Set createJsonSchema: true and the generator emits a static const schema per class — useful for validation, docs, or defining an API contract:

const _$PersonJsonSchema = {
  r'$schema': 'https://json-schema.org/draft/2020-12/schema',
  'type': 'object',
  'properties': {
    'firstName': {'type': 'string'},
    'lastName': {'type': 'string'},
    'dateOfBirth': {'type': 'string', 'format': 'date-time'},
  },
  'required': ['firstName', 'lastName'],
};

Type mapping follows intuition: int → integer, DateTime → string + format: date-time, List/Set → array, Map → object, nested objects via $ref. Doc comments (///) become description fields, and JsonKey.defaultValue is reflected in the schema.

Gotchas in Practice

  • Commit your .g.dart files or regenerate in CI. A stale part file fails at compile time with an unhelpful “undefined” error.
  • explicit_to_json: true matters when you have nested serializable objects — without it, nested toJson() isn’t called and you get Instance of 'Foo' in the map.
  • build_runner is slow on large projects. Use dart run build_runner build --delete-conflicting-outputs in CI and watch-mode (watch) locally.
  • Record support (Dart 3 tuples) is included in 6.14.x — no wrapper class needed for simple pairs.

Further Reading