Skip to content
DevPenh

Flutter JSON Serialization: json_serializable vs freezed

Compare json_serializable and freezed for Flutter JSON models: setup, nested classes, custom keys, equality, copyWith and build_runner fixes.

By Yorn Chanpenh · Last reviewed

Flutter has two common ways to generate JSON code: json_serializable and freezed. They solve the same basic problem — writing fromJson and toJson for you — but they produce different classes and fit different parts of an app. This guide sets up both against the same response, shows what each generator writes, and covers the build errors that waste the most time.

Every example was run on Flutter 3.47.9 / Dart 3.13.7. The package versions were json_serializable 6.15.2, json_annotation 4.12.0, freezed 4.0.3, freezed_annotation 3.1.0 and build_runner 2.17.1. The code passes dart analyze, and the round-trip tests pass with dart test.

One response, two models

Both examples decode this payload:

{
  "id": 101,
  "full_name": "Ada Lovelace",
  "joined_at": "2026-10-28T07:00:00Z",
  "score": 4.5,
  "team": { "id": 7, "name": "Core" },
  "tags": ["flutter", "dart"],
  "last_seen": null
}

Option 1: json_serializable

Use this when you want ordinary Dart classes and a small amount of generated code. Add the runtime annotation package and the two build packages:

dependencies:
  json_annotation: 4.12.0

dev_dependencies:
  build_runner: 2.17.1
  json_serializable: 6.15.2

The class stays hand-written. The annotations supply the JSON names and the generator supplies the two methods:

import 'package:json_annotation/json_annotation.dart';

part 'json_serializable_user.g.dart';

@JsonSerializable(explicitToJson: true)
class JsonUser {
  final int id;

  @JsonKey(name: 'full_name')
  final String fullName;

  @JsonKey(name: 'joined_at')
  final DateTime joinedAt;

  final double score;
  final JsonTeam team;
  final List<String> tags;

  @JsonKey(name: 'last_seen')
  final DateTime? lastSeen;

  const JsonUser({
    required this.id,
    required this.fullName,
    required this.joinedAt,
    required this.score,
    required this.team,
    required this.tags,
    this.lastSeen,
  });

  factory JsonUser.fromJson(Map<String, dynamic> json) =>
      _$JsonUserFromJson(json);

  Map<String, dynamic> toJson() => _$JsonUserToJson(this);
}

@JsonSerializable()
class JsonTeam {
  final int id;
  final String name;

  const JsonTeam({required this.id, required this.name});

  factory JsonTeam.fromJson(Map<String, dynamic> json) =>
      _$JsonTeamFromJson(json);

  Map<String, dynamic> toJson() => _$JsonTeamToJson(this);
}

Run the generator after adding or changing a model:

dart run build_runner build

For the root class, the generated fromJson is the same casts you would write by hand:

JsonUser _$JsonUserFromJson(Map<String, dynamic> json) => JsonUser(
  id: (json['id'] as num).toInt(),
  fullName: json['full_name'] as String,
  joinedAt: DateTime.parse(json['joined_at'] as String),
  score: (json['score'] as num).toDouble(),
  team: JsonTeam.fromJson(json['team'] as Map<String, dynamic>),
  tags: (json['tags'] as List<dynamic>).map((e) => e as String).toList(),
  lastSeen: json['last_seen'] == null
      ? null
      : DateTime.parse(json['last_seen'] as String),
);

The generated class has no custom equality. Two JsonUser objects with the same values are still different objects unless you write == and hashCode yourself.

Option 2: freezed

Use this when you want value equality, copyWith, sealed unions or pattern matching. Freezed generates the class body and can hand JSON to json_serializable:

dependencies:
  freezed_annotation: 3.1.0
  json_annotation: 4.12.0

dev_dependencies:
  build_runner: 2.17.1
  freezed: 4.0.3
  json_serializable: 6.15.2

Freezed 4 requires an abstract class, two part files and a const factory:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'freezed_user.freezed.dart';
part 'freezed_user.g.dart';

@freezed
abstract class FreezedUser with _$FreezedUser {
  @JsonSerializable(explicitToJson: true)
  const factory FreezedUser({
    required int id,
    @JsonKey(name: 'full_name') required String fullName,
    @JsonKey(name: 'joined_at') required DateTime joinedAt,
    required double score,
    required FreezedTeam team,
    required List<String> tags,
    @JsonKey(name: 'last_seen') DateTime? lastSeen,
  }) = _FreezedUser;

  factory FreezedUser.fromJson(Map<String, dynamic> json) =>
      _$FreezedUserFromJson(json);
}

@freezed
abstract class FreezedTeam with _$FreezedTeam {
  const factory FreezedTeam({
    required int id,
    required String name,
  }) = _FreezedTeam;

  factory FreezedTeam.fromJson(Map<String, dynamic> json) =>
      _$FreezedTeamFromJson(json);
}

Freezed needs one analyzer exception. The @JsonSerializable annotation sits on a factory constructor, not a class, so the analyzer reports invalid_annotation_target:

analyzer:
  errors:
    invalid_annotation_target: ignore

Put that in analysis_options.yaml. Without it, the code still generates and runs, but dart analyze reports the warning.

The generated class gives value equality and copyWith without extra code:

final a = FreezedUser.fromJson(jsonDecode(payload) as Map<String, dynamic>);
final b = FreezedUser.fromJson(jsonDecode(payload) as Map<String, dynamic>);

print(a == b);                 // true
print(a.copyWith(score: 5));   // same fields, score changed
print(a.copyWith(score: 5) == a); // false

Round-trip test for both

A model is not finished until it decodes a real response and encodes back to the same shape. This test works for either class:

final user = JsonUser.fromJson(
  jsonDecode(payload) as Map<String, dynamic>,
);

expect(user.fullName, 'Ada Lovelace');
expect(user.joinedAt.toUtc(), DateTime.utc(2026, 10, 28, 7));
expect(user.team.name, 'Core');
expect(user.lastSeen, isNull);

final encoded = jsonEncode(user);
expect(encoded, contains('"full_name":"Ada Lovelace"'));
expect(encoded, contains('"team":{"id":7,"name":"Core"}'));
expect(encoded, contains('"last_seen":null'));

For FreezedUser, the same assertions apply. Add expect(decoded, equals(user)) only for freezed; the json_serializable class does not implement value equality.

Gotchas that cost time

1. Keep toJson deeply map-shaped

explicitToJson: true makes toJson() return a plain map at every level. Without it, a nested model appears as an object:

// Without explicitToJson: true
{team: Instance of 'JsonTeam'}

// With explicitToJson: true
{team: {id: 7}}

jsonEncode can still encode the object because it calls the nested toJson. The difference appears when you inspect or send the map directly, or when another library expects nested maps. Setting it on the parent is the safer default.

2. Custom JSON names belong on the field

Use @JsonKey(name: ...) for full_name, joined_at and last_seen. Do not rename the Dart field to match the API; keep Dart camelCase and map the wire name. The generator keeps the original key in both directions.

3. DateTime.parse throws on bad input

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

DateTime.parse('2026-10-28T07:00:00');
// local DateTime: no offset in the input

The generated code calls DateTime.parse directly. A null date is handled only when the field is nullable; a malformed string still throws. A string without an offset is read as local time, so call .toUtc() when the API means UTC. Validate dates at the boundary or map them to String until you know the format.

4. A missing key and a null value are both null

json['last_seen'] returns null when the key is absent and when it is present with JSON null. A nullable field covers both. Use containsKey only for PATCH-style APIs where “not sent” and “clear it” mean different things. The JSON to Dart null-safety guide covers that distinction in detail.

5. build_runner 2.17.1 ignores --delete-conflicting-outputs

Older guides tell you to run dart run build_runner build --delete-conflicting-outputs. In 2.17.1 that flag is removed. It prints a warning and is ignored, so use the plain command. If stale generated files still cause trouble, clean and rebuild:

dart run build_runner clean
dart run build_runner build

6. Generated files are build output

*.g.dart and *.freezed.dart are written by the generator; do not edit them by hand. Decide whether your repository commits them or generates them in CI, but do not mix both approaches. The part filenames must match the source file.

Which one should you use?

NeedPick
A few small models, no build stepPlain Dart with hand-written methods
Normal API models, explicit fields, minimal generated codejson_serializable
Value equality, copyWith, sealed unions or pattern matchingfreezed
Existing classes you do not want to rewritejson_serializable

A common split is freezed for state and domain objects, and json_serializable for simple request and response models. Both can live in the same project.

Generate the starting classes

The JSON to Dart generator writes both styles from a real response, including nested classes and nullable fields. Paste several items from one endpoint so the generated types reflect what the API actually sends, not one lucky sample. For the nullability rules and common runtime errors, see JSON to Dart models with null safety and Flutter JSON type errors.

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