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.2The 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 buildFor 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.2Freezed 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: ignorePut 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); // falseRound-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 inputThe 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 build6. 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?
| Need | Pick |
|---|---|
| A few small models, no build step | Plain Dart with hand-written methods |
| Normal API models, explicit fields, minimal generated code | json_serializable |
Value equality, copyWith, sealed unions or pattern matching | freezed |
| Existing classes you do not want to rewrite | json_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.