← Flutter Reference

Freezed vs json_serializable

Code Generation

By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.

Quick answer

Use Freezed when you need immutable data classes with copyWith, value equality, sealed unions (success/loading/error states), and JSON. Use json_serializable when you only need JSON (de)serialization on plain mutable Dart classes. Freezed does more; json_serializable does one thing.

They compose — Freezed uses json_serializable under the hood for the JSON part. In practice, reach for Freezed for your state and domain models (which benefit from immutability, unions, and copyWith) and json_serializable for DTOs where a plain class with JSON mapping is enough.

Feature comparison

FeatureFreezedjson_serializable
JSON (de)serializationYes (via json_serializable)Yes
ImmutabilityEnforced (final fields)Optional
copyWithGenerated (null-aware)None
Value equality (==)GeneratedNone (reference)
Sealed unionsFirst-class (.when/.map)No
toStringGeneratedNone
ScopeData/state modelsJSON mapping only
Generated code sizeLargerSmaller
Best forState, domain, union modelsSimple DTOs

Detailed comparison

Scope

json_serializable generates fromJson/toJson methods for a Dart class annotated with @JsonSerializable(). It handles field renaming, nullable vs required, enums, and nested objects. The class itself is an ordinary Dart class — you can make it mutable.

Freezed generates a full immutable data class: private constructor, copyWith with nullable-override semantics, ==/hashCode (value equality), toString, and — its killer feature — sealed unions for modeling state:

@freezed
class AppState with _$AppState {
  const factory AppState.loading() = _Loading;
  const factory AppState.success(Data data) = _Success;
  const factory AppState.error(String message) = _Error;
}
// exhaustive switch via .when/.map

copyWith and equality

Freezed's copyWith handles nested objects and lets you set a field to null (a notorious pain with hand-written copyWith). It generates value equality so two User(name: 'A') instances are equal — essential for BLoC/Riverpod rebuild checks. Plain Dart classes have reference equality by default.

For state classes where you compare previous vs current state to decide rebuilds (BLoC buildWhen, Riverpod select), value equality is not optional — and Freezed gives it for free.

Sealed unions for state

Modeling Loading | Success<T> | Error as a sealed union with exhaustive switch/.when is the idiomatic Dart way to represent async state. Freezed makes this trivial; with plain classes you hand-roll an abstract base and subclasses, which is error-prone.

JSON

Freezed integrates json_serializable for JSON support — add fromJson/toJson and you get both immutability and serialization. So Freezed is a superset for the JSON use case, with the trade-off of more code generation and a slightly steeper learning curve.

When plain json_serializable wins

For a simple API response DTO with five fields, no state modeling, and no need for immutability — json_serializable alone is lighter. You avoid Freezed's generated file size and the union API. Match the tool to the model's role.

Build time and project structure

Freezed generates more code per model than json_serializable — roughly 3-5x the generated file size — because it produces copyWith, equality, hashCode, toString, and union machinery. In a project with 200+ Freezed models, build_runner can take 60-90 seconds on a cold build. The mitigation: use build_runner watch during development (incremental rebuilds are fast) and run full builds only in CI. Structure your project so domain/state models (Freezed) and DTOs (json_serializable) live in separate barrels — this makes the codegen boundary clear and lets you run targeted build_runner on just the layer that changed.

Code comparison

State model — Freezed (union + copyWith + equality)

@freezed
class UserState with _$UserState {
  const factory UserState.loading() = _Loading;
  const factory UserState.success({required User user}) = _Success;
  const factory UserState.error(String message) = _Error;
}

// exhaustive handling
state.when(
  loading: () => Spinner(),
  success: (user) => Profile(user: user),
  error: (msg) => ErrorView(msg),
);

DTO — json_serializable (plain JSON mapping)

@JsonSerializable()
class UserDto {
  final int id;
  final String name;
  UserDto({required this.id, required this.name});
  factory UserDto.fromJson(Map<String, dynamic> json) => _$UserDtoFromJson(json);
  Map<String, dynamic> toJson() => _$UserDtoToJson(this);
}

Use Freezed for state/domain models that need unions, immutability, and equality. Use json_serializable for plain API DTOs. They coexist in the same codebase — different tools for different model roles.

Which should you choose?

Choose Freezed when: you model state (loading/success/error), need immutable data classes with copyWith and value equality, want exhaustive union handling, or your domain entities benefit from immutability. The default for BLoC/Riverpod state classes.

Choose json_serializable when: you only need JSON mapping on a simple DTO, immutability and unions are overkill, or you want minimal generated code. Pair it with Freezed in the same project for different model roles.

Related definitions

Related reading

FAQ

Does Freezed replace json_serializable?

No — Freezed uses json_serializable under the hood for JSON. Freezed adds immutability, copyWith, equality, and unions on top. You can use both in the same project: Freezed for state/domain, json_serializable for plain DTOs.

Is Freezed's generated code slow to build?

It adds to build_runner time but is manageable. For large codebases, use build_runner watch during development and run full builds in CI. The generated-code size is a trade-off for the runtime safety and ergonomics.

Do I need value equality for BLoC states?

Yes, if you rely on buildWhen/select comparing previous vs current state. Hand-writing == for every state class is error-prone; Freezed generates correct equality for free. This is a strong reason to use Freezed for state models.

Can I use Freezed with Dart 3 sealed classes instead of the @freezed union?

Yes, and it is the modern approach. Dart 3 introduced native sealed classes with exhaustive switch, which covers the union-use-case that Freezed pioneered. You can use @freezed with sealed class syntax to get native exhaustive pattern matching plus Freezed's generated copyWith, equality, and JSON. If you only need the sealed union without copyWith or equality, a hand-written Dart 3 sealed class is enough and generates nothing. We use Freezed on top of Dart 3 sealed classes for state models that also need copyWith and value equality — the combination is the best of both.


Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →

Book a 20-minute call · Download the CV (PDF) · See how I work