Skip to content
Blog

Code Generation with build_runner in Dart and Flutter

A practical guide to build_runner: builders, annotations, watch mode, build.yaml configuration, and how to keep generated code fast and out of your way.

Published on • October 8, 2026

AI Assistant

If you have ever written toJson, fromJson, copyWith, or an == overload by hand in Dart, you have felt the problem build_runner exists to solve. build_runner is the build system behind Dart’s code generation ecosystem — serialization, data classes, routing, dependency injection, mocking, theming, and more.

The package describes itself simply: “A build system for Dart code generation and modular compilation.” On pub.dev it ships as build_runner: ^2.16.2 from the official tools.dart.dev publisher, with roughly 6.7 million lifetime downloads.

What a builder actually is

A build_runner generator is called a builder. In the words of the official docs, “a builder adds some capability to your code that is inconvenient to add and maintain in pure Dart. Examples include serialization, data classes, data binding, dependency injection, and mocking.”

Builders come from separate packages. A representative selection from pub.dev:

BuilderAdds capabilitiesNotes
json_serializableJSON serializationFlutter Favorite
freezedData classes, tagged unions, cloningFlutter Favorite
built_value_generatorData classes with JSONFlutter Favorite
riverpod_generatorReactive caching and data bindingFlutter Favorite
drift_devReactive data binding and SQL
go_router_builderType-safe Flutter navigationby Google
injectable_generatorDependency injection
mockitoMocks and fakes for testingby Google
hive_ce_generatorKey-value database bindings
flutter_gen_runnerFlutter asset bindings
slang_build_runnerType-safe i18n

Notice that several are Flutter Favorites — code generation is not a niche concern, it is the mainstream way Flutter apps handle boilerplate.

Getting started

Most builders are activated by an annotation. A minimal json_serializable setup:

# pubspec.yaml
dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.16.2
  json_serializable: ^6.10.0
import 'package:json_annotation/json_annotation.dart';

part 'person.g.dart';

@JsonSerializable()
class Person {
  final String name;
  final DateTime? dateOfBirth;

  Person({required this.name, this.dateOfBirth});

  Map<String, dynamic> toJson() => _$PersonToJson(this);
  factory Person.fromJson(Map<String, dynamic> json) =>
      _$PersonFromJson(json);
}

Then run a build:

dart run build_runner build

Or switch to watch mode, which regenerates whenever sources change:

dart run build_runner watch

Watch mode is where build_runner earns its keep. Add a field to Person, save the file, and the generated toJson/fromJson update before you finish typing.

The commands you will actually use

# One-shot build
dart run build_runner build

# Regenerate everything from scratch (fixes most "stale output" issues)
dart run build_runner build --delete-conflicting-outputs

# Watch mode during development
dart run build_runner watch

# Serve generated output over HTTP (webdev)
dart run build_runner serve

--delete-conflicting-outputs is the single most useful flag. When a builder’s output already exists but does not match what it would generate now — after a package upgrade, say — the build fails rather than overwriting. Deleting and regenerating resolves it.

Workspaces. If you use Dart pub workspaces, you can build several packages at once with the --workspace flag. This is still experimental, but it is the right answer for monorepos where a model package and an app package need generation together.

Output files and source control

Outputs written under lib are immediately available to compilers and IDEs. The docs are explicit about the trade-off:

You can choose whether or not to check generated files into source control. If you publish your package, you must publish the generated files with it. Users getting your package via pub cannot run the build step themselves.

Practical guidance:

  • Applications: checking generated files in is fine and usually preferable — CI becomes faster and diffs show what generation changed.
  • Packages: you must ship the generated files.
  • Either way: .dart_tool/ is build_runner’s private scratch space and must be gitignored.
# .gitignore
.dart_tool/

The docs warn that .dart_tool/build artifacts and .dart_tool/build/generated outputs “are internal to the build… private to build_runner and should not be edited, checked in, published or used in any other way.”

Configuring builders with build.yaml

When you need to control where, when, or how a builder runs, add a build.yaml at your package root.

Restrict a builder to specific files:

targets:
  $default:
    builders:
      json_serializable:
        generate_for:
          - lib/models/*.dart

Pass options to a builder:

targets:
  $default:
    builders:
      freezed:
        options:
          format: true
          copy_with: false
          equal: false

Control build order when two builders depend on each other’s output — the build_config package documents the full build_overrides and ordering model. Most of the time you will not need it; when you do, it is usually because a generated part file must exist before another builder parses the library.

Performance: keeping builds fast

build_runner runs the build in a child process, which has two consequences worth knowing.

1. Incremental builds are the norm, full builds are not. Watch mode only rebuilds what changed. If a build feels slow, check whether something is invalidating the whole graph — editing build.yaml, upgrading a builder package, or touching a shared part file will do it.

2. Debugging requires passing VM args through.

dart run build_runner build \
  --dart-jit-vm-arg=--observe \
  --dart-jit-vm-arg=--pause-isolates-on-start

That prints a DevTools URL you can attach to with “Debug: Attach to Dart Process” in VS Code.

Other practical speed-ups:

  • Use generate_for to stop builders scanning folders they will never emit into.
  • Keep generated files out of analysis hot paths where possible.
  • Split large models across files so only affected libraries rebuild.
  • Run dart run build_runner clean when outputs get into an inconsistent state.

Writing your own builder

For advanced cases you can write a builder against the build package. A builder is a function from a BuildStep to one or more outputs — read input assets, run the analyzer, emit a .g.dart file. Test it with build_test.

You rarely need this. The builders above cover serialization, immutability, routing, DI, i18n, assets, theming, and database bindings. Reaching for a custom builder usually means a codebase-specific pattern worth extracting into a shared package instead.

Common failure modes

“Conflicting outputs.” Another build owns the file. Run with --delete-conflicting-outputs.

Generated file not found after pulling changes. Someone bumped a builder. Run a fresh build; IDE restarts rarely fix it.

part directive missing. Builders emit into part 'x.g.dart' — the annotation and the part line must both exist or the build silently skips the library.

Builds in CI are slow. Cache .dart_tool between runs, and prefer build over watch with a deterministic --delete-conflicting-outputs.

Analyzer errors inside .g.dart. Never edit generated files; your changes vanish on the next build. Fix the annotated source instead.

Wrapping up

build_runner is the connective tissue of the Dart ecosystem: annotations declare intent, builders emit the mechanical code, and watch mode keeps everything in sync while you work. Learn the two commands (build and watch), the one flag (--delete-conflicting-outputs), and the one file (build.yaml), and the entire code generation workflow stops being mysterious.

References