build_runner is the dev-time tool that runs code generators (builders) over your package and writes plain Dart files such as user.g.dart. Flutter relies on it because it has no dart:mirrors: reflection would defeat tree shaking and AOT compilation.
Builders do the work: json_serializable, freezed, riverpod_generator and mockito are builders; build_runner finds them in your dependencies, runs them and tracks which inputs produced which outputs
Annotations say what to generate: @JsonSerializable() or @freezed; the annotation package is a normal dependency, while build_runner and the generators are dev_dependencies and never ship in the app
The output is ordinary code: you can read it, step into it in the debugger, and it costs nothing extra at runtime
The price: an extra build step, generated files to keep in sync, and analyzer errors until the first build has run
FlutterMiddle
Code Generation & build_runner
build_runner, part files, json_serializable, freezed, build.yaml, source_gen, build times
Questions closed
0 / 18
- 0Review
Questions
The generated functions are private (they start with _), so they must live in the same library as your class. A part file shares that library's scope and imports; a file pulled in with import would be a separate library and could not see them.
Both sides declare the link: you write part 'user.g.dart';, the generator writes part of 'user.dart';. Without your line build_runner warns that user.g.dart must be included as a part directive and writes nothing
The part has no imports of its own: every type the generated code uses, like Address, must be imported by user.dart
Why the forwarders: the generator emits only top-level functions; factory User.fromJson and toJson() turn them into the class's public API
Red code before the first build is normal: Target of URI hasn't been generated only means user.g.dart does not exist yet
build generates once and exits, watch keeps running and regenerates only what changed on each save, and clean deletes the build cache in .dart_tool/build. --delete-conflicting-outputs (-d) has been a no-op since build_runner 2.7: stale outputs are always overwritten now.
**What -d used to answer**: older versions stopped with "Found N declared outputs which already exist on disk" after a fresh clone, a branch switch or flutter clean; -d meant "overwrite them"
Passing it is harmless: the flag is accepted and ignored, so old scripts and READMEs keep working
**watch for daily work: the first build pays the full cost, later ones take seconds because the build state stays in memory
clean is a last resort: it throws away exactly the cache that makes the next build fast; use it only when the build state is genuinely broken
A different conflict**: "Both X and Y may output Z" means two builders claim the same file; that is fixed in build.yaml, not with a flag
With annotations: fieldRename on @JsonSerializable renames every key, @JsonKey(name: ...) overrides one field, includeFromJson/includeToJson: false leaves a field out, and @JsonValue plus unknownEnumValue map enum values safely.
**fieldRename: FieldRename.snake**: userId is read and written as user_id; set field_rename: snake in build.yaml to apply it to the whole package
**includeFromJson: false, includeToJson: false**: the current replacement for the old ignore: true; the field needs a default because JSON will never fill it
**@JsonValue('in_progress'): the wire value of an enum member; without it the member's name is used
unknownEnumValue**: without it, a status the server adds next month throws an ArgumentError during decoding and the whole response fails
For an app, usually not: gitignore them and generate them after every clone and in CI. For a published package, yes: dart pub publish skips gitignored files, and the people using your package never run your builders.
Either policy works only if the whole team applies it consistently:
Ignored: a fresh clone shows hundreds of analyzer errors (Target of URI hasn't been generated, undefined _$UserFromJson) until someone runs build_runner; make generation the first step of the setup script and of every CI job
Committed: a clone is green at once and reviews show what changed in the generated code, but PRs get noisy; mark the files linguist-generated=true in .gitattributes so GitHub collapses them
Committed means CI must check freshness: regenerate and fail if the working tree changed, otherwise a stale .g.dart gets merged
Merge conflicts in generated files: never resolve them by hand; take either side and regenerate
The real mistake is mixing both: some generated files tracked and others ignored, so every machine sees a different tree
It prints {id: 42, address: Instance of 'Address'}: by default the generated toJson puts the nested object itself into the map. jsonEncode still produces correct JSON only because it calls toJson() on any object it cannot encode.
The fix: @JsonSerializable(explicitToJson: true), or explicit_to_json: true in build.yaml for the whole package; the generator then emits instance.address.toJson() and converts lists element by element
Where the default bites: anything that uses the map directly: a test comparing toJson() with a fixture map, a row written to sqflite, a map handed to a platform channel or written to Hive/Firestore, a log line
Why it is off by default: backward compatibility; most teams switch it on once in build.yaml and forget about it
The first works, the other two throw a TypeError. The generated code only casts: (json['age'] as num).toInt() accepts 30.0, but json['name'] as String rejects null, and the string '30' is not a num.
No coercion: the generator never parses strings, and a missing key and an explicit null look the same: json['name'] is null either way
Nullable fields fail silently: bio is read with as String?, so missing and null both become null and you cannot tell them apart
Anonymous errors: the bare TypeError says type 'Null' is not a subtype of type 'String' without naming the key
**checked: true**: wraps every field, so a failure arrives as CheckedFromJsonException with className and key; catch it at the repository boundary
Defaults and coercion: @JsonKey(defaultValue: ...) or a constructor default covers missing and null; @JsonKey(fromJson: ...) is where parsing '30' belongs
Write a JsonConverter<T, Json> with fromJson and toJson, and attach it to the field, to the class, or through converters: in @JsonSerializable. The generator then calls your converter instead of emitting a cast.
**const constructor: the converter is used as an annotation, so it must be a const instance
Matching is by Dart type**: converters: [MoneyConverter()] applies to every Money field and to the elements of a List<Money>
Nullable fields are covered: a JsonConverter<DateTime, int> also handles DateTime?; the generated code passes null through before calling it
One-offs: @JsonKey(fromJson: _parse, toJson: _format) with top-level or static functions does the same without a class
**DateTime already works**: as an ISO-8601 string; you need a converter only when the API uses another format
The generated toJson writes every field, null included, so the body is {"name":"Ann","bio":null,"avatar_url":null}. In a PATCH an explicit null means "clear this field", so the server erases the bio and the avatar.
Why: includeIfNull defaults to true: the generator emits a map literal with every key, and a nullable field that was never set is simply null, the same as one cleared on purpose
The fix: includeIfNull: false on the class, @JsonKey(includeIfNull: false) on one field, or include_if_null: false in build.yaml; the generator then emits null-aware entries ('bio': ?instance.bio), so a null field leaves no key
The price: this DTO can no longer clear a field; if the API clears with null, send that key from a hand-built map or model "not set" and "clear" as different values
Catch it: a unit test that ProfilePatch(name: 'Ann').toJson() equals {'name': 'Ann'}, plus an interceptor that logs request bodies in debug builds
freezed generates field-by-field ==/hashCode, toString, a copyWith that can also set a field to null, and the classes behind factory constructors. A plain sealed class or @JsonSerializable is enough when you need neither value equality nor copyWith.
Two part files: user.freezed.dart comes from freezed, user.g.dart from json_serializable; freezed only wires fromJson to it
**The copyWith detail**: a generated helper tells "not passed" from "passed null", so copyWith(avatarUrl: null) really clears the field; a hand-written copyWith built on ?? cannot
Unions and JSON: with fromJson a union gets a type discriminator, written by json_serializable
**Plain sealed is enough**: for a union that stays inside the app and needs neither copyWith nor value equality: Dart 3 already gives exhaustive switch and destructuring
**Plain @JsonSerializable is enough: for a DTO you decode once and never compare or copy: one builder instead of two and far less generated code
The cost**: hundreds of generated lines per model and extra build time, so spend it where equality and copyWith pay off: state classes and domain models
Freezed 3 requires the annotated class to be abstract (a single data class) or sealed (a union). Point is a concrete class that mixes in _$Point, and in freezed 3 that mixin declares x and y as abstract getters, so the analyzer reports missing implementations.
The fix: abstract class Point with _$Point; with several factory constructors use sealed class and get an exhaustive switch for free
Why it compiled before: freezed 2 gave every mixin getter a stub body that threw _privateConstructorUsedError, so a concrete class was accepted
Why it changed: freezed 3 added "mixed mode", where a @freezed class can also be an ordinary class with its own constructor and final fields, so the generated mixin can no longer pretend to implement them
The other freezed 3 break: when/map were removed in 3.0 and returned in 3.1 only as discouraged helpers; switch expressions are the intended replacement
Migration order: bump freezed and freezed_annotation together, regenerate, then fix the analyzer errors class by class
Freezed uses the constructor name as the union value, so the generated fromJson matches 'screenView', never 'screen_view'. Every such event throws CheckedFromJsonException with Invalid union type "screen_view"!, and a response that contains one fails to parse.
Why the tests pass: the generated toJson also writes type: screenView, so Event.fromJson(e.toJson()) agrees with itself; only the backend's snake_case values expose the mismatch
The fix: unionValueCase: FreezedUnionCase.snake maps every constructor name, @FreezedUnionValue('screen_view') renames one, and union_value_case: snake in build.yaml sets it for the whole package
**Not covered by fieldRename**: json_serializable's key renaming never touches union values; the discriminator is configured on @Freezed only
Unknown types: fallbackUnion with an Event.unknown() constructor keeps an event type the server adds later from breaking the whole response
Catch it: decode fixtures copied from real API responses, not objects your own toJson produced
build.yaml at the package root configures the builders for that package: which files each one reads (generate_for), its options, and whether it runs at all (enabled). Narrowing generate_for to the folders that hold annotated classes is the cheapest build-time win.
Builder keys: package:builder, such as json_serializable:json_serializable (or just json_serializable when the names match) or freezed:freezed; the exact names are in that package's own build.yaml
Options replace repeated annotations: explicit_to_json, field_rename or include_if_null set here apply to every @JsonSerializable in the package
Why narrowing helps: by default every builder sees every Dart file in the package; a builder that never sees a file never asks the analyzer to resolve it
Trap: generate_for filters inputs silently: a model added outside the glob simply stops generating, and you find out from an undefined _$... function
Trap: globs are relative to the package root and need ** to recurse: lib/models/*.dart skips subfolders
ARB files are not part of the build_runner pipeline. AppLocalizations is generated by flutter gen-l10n, a Flutter tool command configured by l10n.yaml, which also runs on flutter run and flutter build when generate: true is set in pubspec.yaml.
Output lands in your sources: files are written to arb-dir by default (output-dir, for example lib/l10n, moves them) and imported normally; the synthetic package:flutter_gen/gen_l10n/... package has been removed from the Flutter tool, and the synthetic-package key no longer does anything
Typed asset accessors are different again: Assets.images.logo comes from flutter_gen_runner, which is a build_runner builder
Missing translations are silent: a key absent from app_ru.arb falls back to the template language; set untranslated-messages-file (the tool writes {} there when nothing is missing) and fail CI when the file contains anything else
Who generates what: json_serializable, freezed, riverpod_generator, mockito, drift: build_runner; AppLocalizations: flutter gen-l10n; GeneratedPluginRegistrant: the Flutter tool on every build
Mostly into the analyzer resolving libraries for the builders: each annotated file is resolved together with everything it imports. You win time back by letting fewer files reach fewer builders, keeping the build cache warm, and staying on a current build_runner.
Where the time goes
Resolution, not writing: a model that imports a widget file drags the widget layer, and everything it imports, through the analyzer
Every builder on every input: without generate_for, json_serializable, freezed and mockito all inspect every Dart file in the package
Cold starts: clean, a deleted .dart_tool/build or a fresh CI runner force a full rebuild
What wins it back
Narrow inputs: generate_for globs in build.yaml so each builder sees only its folders; newer builders also support run_only_if_triggered and skip files without their annotation
Keep models' imports small: DTOs and state classes in their own libraries or packages resolve far faster
**Use watch: the first build is cold, later ones are incremental and take seconds
Cache .dart_tool/build in CI**: keyed on pubspec.lock and the SDK version; never run clean by reflex
Upgrade: build_runner 2.10–2.14 added AOT-compiled builders (on by default) and several 2–4x incremental speedups; an old pin gives that up
Measure first: the log shows which builder is slow, and --build-filter regenerates one output when that is all you need
Subclass GeneratorForAnnotation<T> and return the code to emit from generateForAnnotatedElement, wrap it in a SharedPartBuilder inside a top-level factory function, and declare that factory in the generator package's build.yaml.
Three packages: the annotation (plain Dart, a normal dependency), the generator (depends on analyzer, build, source_gen; a dev dependency of the app), and the app itself
**element**: the analyzer's resolved model of the annotated declaration: fields, types, supertypes. That is why every major analyzer release ripples through all generators
**ConstantReader annotation**: reads the evaluated annotation, e.g. annotation.read('name').stringValue
Errors: throw InvalidGenerationSourceError(message, element: element) so the build points at the user's declaration instead of producing broken code
**SharedPartBuilder**: writes a .analytics.g.part fragment that source_gen:combining_builder merges into the shared .g.dart; use PartBuilder or LibraryBuilder for a file of your own
**No dart:mirrors**: source_gen 4 replaced TypeChecker.fromRuntime with TypeChecker.typeNamed, so builders can be AOT-compiled; avoid mirrors in your own code too
Test it: testBuilder from build_test runs the builder on in-memory sources and compares the outputs
A builder applies only to packages that depend on its package directly (auto_apply: dependents), and a plain run writes source outputs only for the package it runs in. Each feature package needs its own dev dependency on the generator, and generation runs per package or with --workspace.
The dependency edge: feature_cart must list json_serializable (or your core_gen) in its own dev_dependencies; having it only in app/ applies nothing to feature_cart
Split annotations from generators: core_annotations is plain Dart and a normal dependency; core_gen pulls in analyzer, build and source_gen and stays a dev dependency, so none of it ships
Pub workspaces: with a workspace: list in the root pubspec.yaml (Dart 3.6+), dart run build_runner build --workspace (build_runner 2.11+, stable since 2.14) builds every package in one process with one shared analyzer
Without a workspace: melos exec --depends-on=build_runner -- dart run build_runner build runs it per package; add --order-dependents when one package's generator reads another package's generated code
One resolution, one analyzer: every generator in a workspace must accept the same analyzer version, so one outdated generator can block upgrades for all packages
The Dart team cancelled macros in January 2025 before they shipped: running user code inside the compiler and analyzer on every keystroke cost too much in IDE speed and hot reload. Code generation stays on build_runner, and that effort went into making it faster.
Nothing to migrate: macros only ever existed behind an experiment flag; @JsonCodable never reached a stable SDK
What changed instead: 2025–2026 build_runner releases brought AOT-compiled builders, much faster incremental builds and --workspace builds; generators dropped dart:mirrors to make AOT possible
Augmentations are separate: a language proposal, not part of the macro cancellation, with no ship date; they would let a generated part add members straight into your class (no with _$User, no _$UserFromJson forwarders) and change what generated code looks like, not how it is produced
Practical stance: keep using json_serializable, freezed and riverpod_generator, and control build times with generate_for, watch and a cached .dart_tool/build