Skip to content

Dart Build Runner Explained — Code Generation Guide with Examples

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Dart Build Runner Explained. We cover key concepts, practical examples, and best practices to help you master this topic.

Dart build runner is a code generation tool that automates boilerplate creation for JSON serialization, data classes, dependency injection, and more through source_gen-based packages like freezed and json_serializable.

What You Will Learn

  • What build runner is and why code generation matters in Dart
  • How to configure build_runner in a Dart or Flutter project
  • Using json_serializable for automatic JSON encoding and decoding
  • Generating immutable data classes with freezed
  • Handling build failures and optimizing rebuilds with the build cache
  • Writing custom generators with the source_gen package

Why It Matters

Manual boilerplate code is error-prone, tedious, and hard to maintain. Every time you add a field to a JSON model, you must update the fromJson and toJson methods, write copyWith functions, and implement equality checks. In large projects with hundreds of models, this becomes a significant maintenance burden. Build runner automates all of this: you write the data class definition once, add the right annotations, and the generator produces the implementation for you. This pattern is used in production apps built for Doda Browser and Durga Antivirus Pro to handle hundreds of API response models without manual serialization code.

Real-World Use

A weather app receiving data from a REST API defines a Forecast model with 15 fields. Without code generation, you write fromJson, toJson, ==, hashCode, and copyWith manually — that is roughly 80 lines of repetitive code per model. With build runner and freezed, you write 10 lines of class definition, run build_runner, and get all the boilerplate automatically. When the API adds a new field, you update one line in your definition and regenerate.

Learning Path

flowchart LR
  A[FFI] --> B[Build Runner\nYou are here]
  B --> C[Dart Packages]
  style B fill:#f90,color:#fff

What Is Build Runner?

Build runner is the Dart ecosystem's code generation framework. It uses the source_gen library to Process Dart source files, look for annotations, and generate new .dart files with the implementation those annotations promise.

The key concept is source generation: you annotate your code with special markers (like @freezed or @JsonSerializable), and build_runner reads those markers, analyzes the surrounding code, and writes generated files alongside your originals. For example, if you have person.dart with annotations, build_runner produces person.g.dart containing the generated code.

How It Works

Build runner operates in three phases:

  1. Resolution: It resolves all Dart source files in your project and identifies those with generator annotations.
  2. Generation: Each registered Builder (a class implementing Generator) processes the annotated files and produces new source content.
  3. Writing: The generated content is written to .g.dart files, which you import and use in your main code.
// person.dart — you write this
import 'package:json_annotation/json_annotation.dart';

part 'person.g.dart';

@JsonSerializable()
class Person {
  final String name;
  final int age;
  final String? email;

  Person({required this.name, required this.age, this.email});

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

  Map<String, dynamic> toJson() => _$PersonToJson(this);
}

After running dart run build_runner build, the file person.g.dart is generated:

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'person.dart';

Person _$PersonFromJson(Map<String, dynamic> json) => Person(
      name: json['name'] as String,
      age: (json['age'] as num).toInt(),
      email: json['email'] as String?,
    );

Map<String, dynamic> _$PersonToJson(Person instance) => <String, dynamic>{
      'name': instance.name,
      'age': instance.age,
      'email': instance.email,
    };

Output: The generated file is created automatically. You never edit person.g.dart by hand.

Setting Up Build Runner

Add the required dependencies to your pubspec.yaml:

dependencies:
  json_annotation: ^4.8.0

dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.7.0

Run dart pub get to install them. Then create your annotated model class and run the builder:

dart run build_runner build

This command scans your project, runs all registered builders, and generates the output files. For Flutter projects, use dart run build_runner build the same way — it works across both ecosystems.

Build Modes

Build runner offers two execution modes:

  • One-time build (dart run build_runner build): Generates files once and exits. Use this before committing or deploying.
  • Watch mode (dart run build_runner watch): Continuously watches for file changes and regenerates affected files. Use this during development for instant feedback.
# One-time build
dart run build_runner build --delete-conflicting-outputs

# Watch mode for development
dart run build_runner watch

The --delete-conflicting-outputs flag tells build_runner to remove old generated files that may conflict with new ones. This is useful when you rename or remove model classes.

Generating Immutable Data Classes with Freezed

The freezed package takes code generation further by creating immutable data classes with copyWith, union types, pattern matching, and deep equality — all from a single class definition.

// user.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    @Default('active') String status,
    String? avatarUrl,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) =>
      _$UserFromJson(json);
}

Run dart run build_runner build and freezed generates:

  • operator == and hashCode based on all fields
  • copyWith() method for creating modified copies
  • toString() with field values
  • JSON serialization glue via json_serializable
  • Union type support for sealed class patterns
void main() {
  final user = User(id: 1, name: 'Alice');
  final updated = user.copyWith(status: 'inactive');

  print(user == updated);
  print(updated.toString());
}

Output:

false
User(id: 1, name: Alice, status: inactive, avatarUrl: null)

The copyWith method returns a new immutable instance with only the specified fields changed — an essential pattern for state management in Flutter apps.

Handling Build Failures

Build runner can fail for several reasons. Here are the most common fixes:

Missing Part Directive

Every file using generated code must include a part directive that matches the generated filename. Without it, the build succeeds but the generated code is never linked.

// Wrong — no part directive
@JsonSerializable()
class Person { ... }

// Correct
part 'person.g.dart';

@JsonSerializable()
class Person { ... }

Conflicting Outputs

If you rename a class or change annotations, old generated files may conflict. Use the delete flag to clear them:

dart run build_runner build --delete-conflicting-outputs

Cyclic Dependencies

Build runner cannot handle circular imports between generated files. If file A imports B and B imports A, the build fails. Restructure your code to avoid cycles — typically by extracting shared types into a separate file.

Incremental Build Problems

Sometimes the build cache gets corrupted. Reset it with:

dart run build_runner clean
dart run build_runner build

This deletes the .dart_tool/build cache directory and forces a full rebuild.

Custom Generators with source_gen

For advanced use cases, you can write your own generator. A custom builder can automate repetitive patterns specific to your project, such as generating route helpers, dependency injection code, or database mappers.

// lib/src/uppercase_generator.dart
import 'package:analyzer/dart/element/element.dart';
import 'package:build/src/builder/build_step.dart';
import 'package:source_gen/source_gen.dart';
import 'package:analyzer/dart/constant/constant_utils.dart';

class UppercaseGenerator extends Generator {
  @override
  String generate(LibraryReader library, BuildStep buildStep) {
    final buffer = StringBuffer();
    for (final cls in library.classes) {
      final annotation = cls.metadata
          .where((m) => m.element!.name == 'uppercase')
          .firstOrNull;
      if (annotation != null) {
        buffer.writeln('extension ${cls.name}Extension on ${cls.name} {');
        buffer.writeln('  String get upper => toString().toUpperCase();');
        buffer.writeln('}');
      }
    }
    return buffer.toString();
  }
}

Register the builder in build.yaml:

targets:
  $default:
    builders:
      my_generator:
        import: "package:my_package/src/uppercase_generator.dart"
        builder_factories:
          - uppercaseBuilder
        build_extensions:
          .dart: .g.dart
        auto_apply: dependents
        build_to: source

Common Mistakes

  1. Forgetting the part directive: Every file that uses generated code must have a part statement referencing the output file. Without it, the generated code is unreachable and you get compilation errors when trying to use methods from the generated file.

  2. Editing generated files manually: Generated files are overwritten every time you run build_runner. Any manual edits are lost. If you need custom behavior, extend the class or write a mixin rather than modifying .g.dart files.

  3. Not deleting conflicting outputs after renaming: If you rename a Dart file or a class, the old generated file remains in place. Build runner may pick it up and cause confusing errors. Always use --delete-conflicting-outputs after structural changes.

  4. Running build_runner with unsaved changes: Build runner reads files from disk, not from your IDE buffer. Save all files before running a build to ensure the generator sees your latest code.

  5. Using package-level builders without build.yaml: Some packages (like json_serializable) include builders that must be registered in a build.yaml configuration. If code generation is not producing output, check that build.yaml is correctly configured.

  6. Slow builds from unnecessary rebuilds: Build runner rebuilds everything by default. For large projects, use --build-filter to target specific files: dart run build_runner build --build-filter="lib/models/**".

  7. Ignoring the analyze phase: Run dart analyze before build_runner build to catch syntax errors early. A single file with errors can halt the entire generation process.

Practice Questions

  1. What is the purpose of the part directive in a file using build runner?
  2. How does freezed improve upon json_serializable for data classes?
  3. What does the --delete-conflicting-outputs flag do, and when should you use it?
  4. Why should you never manually edit a .g.dart file?
  5. Challenge: Create a custom builder that generates a toMap() method for every class annotated with @toMap. The method should return a Map<String, dynamic> with all public fields.

Mini Project

Build a data layer for a note-taking app using build runner:

  • Define a Note class with fields: id, title, content, createdAt, updatedAt, isArchived
  • Use freezed for immutability and json_serializable for JSON conversion
  • Define a Notebook class containing a list of Note objects with the same annotations
  • Write a main function that creates sample notes, serializes them to JSON, prints the JSON, deserializes back, and verifies equality
  • Use copyWith to archive a note and print the modified state

FAQ

What is the difference between build_runner and source_gen?

build_runner is the build system that orchestrates file watching, caching, and output writing. source_gen is the library for writing individual generators that produce code. Build runner runs source_gen-based generators.

Can I use build_runner with Flutter?

Yes. Build runner works identically in Dart and Flutter projects. Add the dependencies to pubspec.yaml and run dart run build_runner build from your Flutter project root.

How do I speed up build_runner in large projects?

Use --build-filter to target specific directories, exclude test directories in build.yaml, and run build_runner in watch mode during development so only changed files are regenerated.

Why does build_runner say 'Invalidated: 0 files'?

This usually means the builder is not registered correctly in build.yaml, or the annotation is not being detected. Check that your build.yaml has the correct builder configuration and that your source file imports the annotation package.

Is build_runner safe for production builds?

Yes. Build runner is used by major Flutter and Dart projects including the Dart SDK itself. The generated code is deterministic and follows best practices.

What is Next

Proceed to Packages to learn how to create and publish reusable Dart packages. Then explore WebSockets for real-time communication patterns that complement your data layer skills.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro