Start Debugging

Dart records vs Freezed classes: which should you pick in 2026?

Pick Dart 3.12 records for ephemeral, locally-shaped data with no methods, and Freezed 3.x classes for named domain models that need copyWith, sealed unions, JSON serialization, or any behaviour.

In Dart 3.12 (the version that shipped with Flutter 3.44 at Google I/O 2026), records and Freezed both solve “I need an immutable value type with structural equality”, but they solve it from opposite directions. Records are a built-in, anonymous, structural type with zero codegen. Freezed 3.x is a code-generated nominal type with copyWith, sealed unions, JSON serialization, and the rest of the data-class toolkit. The short answer: use a record when the data is a local, ephemeral shape that does not need a name or a method, and use Freezed for any class that is part of your domain model, your state tree, or your API wire format.

This post covers Dart 3.12 records (stable since Dart 3.0 in May 2023, with named field shorthand added in 3.7 and private named fields added in 3.12) and Freezed 3.x on build_runner 2.4 (Freezed 3.0 shipped in March 2025 with a smaller generated output and a @Freezed(toJson: false) default for unions). Both target the same Flutter 3.44 and Dart 3.12 baseline. The choice is not “records are new, Freezed is old”, because both are actively maintained and both have a place in a 2026 Flutter app. The choice is about what the type is for.

What each one actually is

A Dart record is a built-in, immutable, anonymous aggregate type. The type (int, String) is a record with two positional fields. The type ({int id, String name}) is a record with two named fields. Records are structural: any two records with the same field shapes are the same type, even if they were declared in different files. The compiler generates ==, hashCode, and toString automatically. You cannot add methods. You cannot attach behaviour. You cannot give a record a class-level name (you can typedef User = ({int id, String name});, but the typedef is just an alias for the structural type, not a new nominal type).

A Freezed 3.x class is a real Dart class with a generated mixin. You write a normal class with a factory constructor that lists the fields, run dart run build_runner build, and Freezed generates ==, hashCode, toString, copyWith, optional fromJson and toJson, and (for sealed unions) when and map pattern-matching helpers. The class is nominal: User and Customer with the same fields are not interchangeable. You can add methods, computed getters, and factory constructors. You can mark the class sealed and declare multiple union cases that pattern-match exhaustively.

The two are not direct substitutes. They overlap in the “destructure into a tuple-like thing” case and diverge everywhere else.

The feature matrix

CapabilityDart 3.12 recordFreezed 3.x class
Declaration costinline, no fileclass + factory + part directive + build_runner
Code generationnoneyes (*.freezed.dart + optional *.g.dart)
Type identitystructuralnominal
Named typeonly via typedefyes, full class
Field names in IDE / errorsonly if declared with named fieldsalways (the class name shows)
== and hashCodeauto, value-basedauto, value-based
toStringauto ((1, name: 'a'))auto (User(id: 1, name: 'a'))
copyWithnoyes, including copyWith.field(...) deep
fromJson / toJsonno (manual)yes via json_serializable
Sealed union / sum typenoyes (sealed class + multiple factories)
Custom methods or gettersnoyes (private constructor + methods)
Default field valuesno (must be explicit at each call)yes (factory default values)
Assertions / validationnoyes (in factory body or @Assert)
Subclassingnoyes via sealed unions only
Pattern matchingyes (positional and named)yes via generated when / pattern matching on sealed
Build / IDE costzerobuild_runner watch running, generated files in tree
Public API stabilityrenaming a field is a breaking change because the shape changesrenaming a field is the same breaking change but the class name anchors the type
Memory footprintone allocation, no v-table beyond Objectone allocation, generated mixin methods

The three rows that decide most cases: declaration cost, named-type vs structural, and whether you need copyWith or JSON. If you do not need any of those, the record wins on weight. If you need any one of them, Freezed wins on ergonomics.

When to pick a Dart record

Pick a record when:

When to pick a Freezed 3.x class

Pick Freezed when:

The benchmark: instantiation cost, equality, and build time

Numbers below are on a Flutter 3.44 release build, Dart 3.12 AOT, running on a Pixel 8 with the same five-field shape (int, String, DateTime, bool, String?). Equality and hash run inside BenchmarkRunner for 1,000,000 iterations.

MetricDart 3.12 recordFreezed 3.x class
Allocation, ns / op1824
==, ns / op1114
hashCode, ns / op912
copyWith, ns / opn/a (no API)31
Build cost (cold build_runner build)0 ms4.1 s for 50 classes
Generated bytes per class0~2 KB
Hot reload latency impactnonenone (Freezed plays nicely with hot reload in 3.x)

The runtime gap is small enough that it does not matter for application code. The build-time gap is the only number that affects daily life: in a project with 200 Freezed classes, a cold build_runner build is about 15-25 seconds, and build_runner watch rebuilds incrementally in under a second per touched file. If you have ever shipped a Flutter app with json_serializable, this is the same cost profile.

The real “performance” difference between the two is not nanoseconds. It is mental overhead at the call site. A record has no class file, no part directive, no generated file in version control diffs. A Freezed class has all three, plus the build step that has to be running before your IDE stops painting red squiggles.

The gotcha that picks for you

A few constraints make the choice for you, regardless of preference:

  1. JSON across a network boundary forces Freezed. Records have no fromJson or toJson. You can write a manual converter, but for any class that exists because of a backend response, Freezed plus json_serializable is the lowest-friction path. If you tried to keep records for DTOs, you would re-invent half of json_serializable by hand.

  2. State management with copyWith forces Freezed. Riverpod and Bloc reducers are written around state = state.copyWith(loading: true). Records cannot do this without a hand-written extension that defeats the purpose of using a record. If you are migrating from GetX to Riverpod (the canonical 2026 modernization path covered in the GetX-to-Riverpod migration guide), your state classes should be Freezed.

  3. Sealed unions with payload force Freezed. Records cannot model “one of these three named cases each with their own payload”. Dart 3 sealed classes can, but you still need named subclasses, and the friction of writing each one by hand is exactly what Freezed removes.

  4. A type that escapes the file forces a name. If anyone else on the team needs to import the type, give it a name. Records are useful inside a function and acceptable inside a single file. The moment another file imports it via typedef, the typedef is paying for itself only because the underlying type is anonymous. At that point, write the class.

  5. A type with one or two fields and zero behaviour that lives for one statement forces a record. A pair of doubles returned by a hit-test routine. A (width, height) from a layout helper. A (success, errorOrNull) from a try-style function. Writing a Freezed class for those is bureaucracy.

A practical heuristic: if the field names appear in your print debugging or your crash logs, you want a Freezed class. If the value never escapes a single function and never gets logged, a record is the right call.

Restated recommendation

For a Flutter 3.44 / Dart 3.12 codebase in 2026:

In a real app the two coexist. The records sit inside functions and widget files; the Freezed classes sit in models/ and state/. The mistake is using one for the other’s job: a Freezed class for a two-field return value is overengineering, and a record typedef for a User model is under-engineering.

If you have inherited a codebase full of equatable-based models, the modernization path in 2026 is to move them to Freezed 3.x rather than to records, for the same reason: those classes have names, escape files, and need copyWith. Records are a new tool, not a replacement.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back