Flutter Pigeon: Type-Safe Platform Channels in Production

What Flutter Pigeon is, how one Dart contract generates both sides of a platform channel, and the production gotchas most tutorials skip.

10 min read

Flutter Pigeon is Flutter’s first-party code generator for type-safe platform channels. It replaces the hand-written MethodChannel string keys and manual map serialization that Flutter native code integration usually starts with. You write an API contract in a Dart file, run codegen, and get type-safe Dart bindings plus native host bindings in Swift, Kotlin, and the other supported languages.

This post shows what that buys you in production and the gotchas most tutorials skip.

Why type safety matters

A raw MethodChannel is a string and an untyped bag of values. Here is the shape almost every native integration starts with.

const channel = MethodChannel('my_plugin');

Future<String?> getPlatformName() async {
  return channel.invokeMethod<String>('getPlatformName');
}

On the native side you match that string by hand, pull arguments out of a map, and cast them. Nothing checks that the Dart 'getPlatformName' matches the native handler, that the argument keys line up, or that the return type is what Dart expects. A rename on one side and a stale string on the other compiles clean and fails at runtime, often only on the platform you did not test that day. The more methods and platforms you add, the worse it gets.

How Pigeon works: contract in, codegen out

Pigeon moves the contract into a single Dart file and generates both sides from it. Add it as a dev dependency with dart pub add dev:pigeon, keep the contract in a pigeons/ directory outside lib, and declare an annotated abstract class.

import 'package:pigeon/pigeon.dart';

@HostApi()
abstract class MyPluginApi {
  String? getPlatformName();
}

Running dart run pigeon --input pigeons/messages.dart generates every side of the channel from that one contract. On the Dart side you get a ready client with a real method to call.

final api = MyPluginApi();
final name = await api.getPlatformName();

On iOS and macOS you get a Swift protocol the compiler forces you to implement.

class MyPlugin: MyPluginApi {
  func getPlatformName() throws -> String? {
    "macOS"
  }
}

On Android you get the matching Kotlin interface.

class MyPlugin : MyPluginApi {
  override fun getPlatformName(): String? = "Android"
}

Same method, same types, three languages, zero hand-written channel code. The method names, argument types, and return types are enforced by the Dart analyzer and the native compilers. Rename the method in the contract, regenerate, and every side stops compiling until you update it. That is the whole pitch. A breaking change fails at compile time instead of at runtime.

A @ConfigurePigeon annotation with PigeonOptions pins the output paths inside the contract itself, so codegen needs nothing more than the input path. Generators cover Swift, Kotlin, Objective-C, Java, C++, and GObject. The one platform Pigeon skips is web, which talks to JavaScript interop rather than a host method channel, so there is no native side to generate.

Even a one-method contract benefits, and the value compounds the moment you add a structured payload, an enum, or a second platform.

A realistic contract with an enum and async calls

Real contracts mix data types and call styles. Here is a contract for a media-permissions surface on macOS. Dart asks native Swift for camera and microphone permission state and prompts for access through the macOS TCC permission system.

import 'package:pigeon/pigeon.dart';

@ConfigurePigeon(
  PigeonOptions(
    dartOut: 'lib/src/gen/media_permissions.pigeon.dart',
    swiftOut: '../../app/macos/Runner/Generated/MediaPermissions.gen.swift',
    dartPackageName: 'media_permissions',
  ),
)
enum MediaPermissionStatus { notDetermined, denied, restricted, authorized }

@HostApi()
abstract class MediaPermissionsHostApi {
  MediaPermissionStatus microphoneStatus();
  @async
  bool requestMicrophone();
  bool openMicrophoneSettings();
  MediaPermissionStatus cameraStatus();
  @async
  bool requestCamera();
  bool openCameraSettings();
}

The MediaPermissionStatus enum crosses the boundary as a real type, so Dart switches on it exhaustively instead of comparing magic strings. You implement the generated protocol in native Swift and register it once on the binary messenger with the generated setup helper.

The read-only status calls stay synchronous and the prompting calls are marked @async. Anything that prompts the user or awaits native work is async. Anything that reads cached state is not. The generated Swift makes the difference concrete.

class MediaPermissions: MediaPermissionsHostApi {
  func microphoneStatus() throws -> MediaPermissionStatus {
    // Sync: return directly. A throw becomes a
    // PlatformException on the Dart side.
    .authorized
  }

  func requestMicrophone(completion: @escaping (Result<Bool, Error>) -> Void) {
    AVCaptureDevice.requestAccess(for: .audio) { granted in
      // The system invokes this callback on an arbitrary queue.
      // Channel replies must go out on the main thread.
      DispatchQueue.main.async {
        completion(.success(granted))
      }
    }
  }
}

The sync method returns its value directly. The @async method receives a completion callback and hands the result back whenever the native work finishes, which is exactly what a permission prompt needs. Note the hop to the main queue. AVCaptureDevice invokes its callback on an arbitrary queue, and messages to Flutter must be sent from the platform’s main thread, so dispatch back before calling the completion.

Both directions with HostApi and FlutterApi

Some features need traffic both ways. A native panel that Dart drives and that reports user actions back uses a @HostApi for Dart-to-native pushes and a @FlutterApi for native-to-Dart callbacks.

@HostApi()
abstract class NativePanelHostApi {
  void open();
  void close();
  void pushMessages(List<PanelMessage> messages);
  void pushPresence(PanelPresence presence);
}

@FlutterApi()
abstract class NativePanelFlutterApi {
  void didSubmitComposer(String text);
  void didToggleMic();
  void didRequestClose();
}

Pigeon generates both directions from this one file, so the panel’s entire two-way contract reads top to bottom in a single place. Your custom classes cross the boundary as typed objects, and you never hand-write serialization.

The native side of a @FlutterApi is a generated client you call, the mirror image of implementing a @HostApi.

let panel = NativePanelFlutterApi(binaryMessenger: messenger)
panel.didSubmitComposer(text: text) { _ in }

Dart implements the abstract class and registers it once.

class PanelHandler implements NativePanelFlutterApi {
  @override
  void didSubmitComposer(String text) => sendMessage(text);
  // ...
}

NativePanelFlutterApi.setUp(PanelHandler());

Beyond basic calls

Those three annotations, @HostApi, @FlutterApi, and @async, cover most boundaries. When yours gets richer, Pigeon has more.

@EventChannelApi, added in Pigeon 22.7.0, streams values from the host to Dart. You annotate methods that each declare the streamed value type, not the Stream wrapper, and Pigeon generates a Dart Stream you consume with await for.

@EventChannelApi()
abstract class EventChannelMethods {
  // Returns the streamed type, NOT Stream<PlatformEvent>.
  PlatformEvent streamEvents();
}

@ProxyApi generates a Dart proxy that mirrors a native object and cleans it up automatically when the Dart side is collected. This is how the big first-party plugins wrap native SDK classes. webview_flutter_android uses it. Both @EventChannelApi and @ProxyApi generate for Dart, Kotlin, and Swift.

Production-proven inside Flutter’s own plugins

Pigeon backs the type-safe native bridge in Flutter’s own first-party plugins, published by flutter.dev under the flutter/packages monorepo. webview_flutter, camera, in_app_purchase, google_maps_flutter, and video_player all run their platform-channel layer through Pigeon, each carrying a pigeons/ directory and a pigeon dev_dependency. Plugins such as file_selector, google_sign_in, quick_actions, local_auth, url_launcher, image_picker, and shared_preferences follow the same pattern.

It is not universal. Some Apple-platform plugins generate their bindings with ffigen instead. Pigeon is the default for plugin-style platform-SDK work. Nothing forces it.

Where Pigeon sits versus FFI

Pigeon is codegen on top of platform channels. It keeps the async, serialized message-passing model and removes the boilerplate and the runtime type mismatches. The contrast that matters is with dart:ffi, which calls native code directly. Pick the tool for what you are talking to. Raw speed rarely decides it.

ToolBest forTradeoffs
Raw MethodChannel / EventChannelA single throwaway native call with primitive data.Not type-safe. Names and argument shapes are matched by hand, so mismatches surface at runtime.
PigeonAny typed native surface you maintain that calls platform-SDK APIs. Flutter’s recommended alternative to raw channels.Inherits the channel runtime model, so it is slower than FFI for hot paths.
dart:ffiCalling C or C-ABI libraries and CPU-intensive code directly, synchronously, with no serialization.C-ABI level. Manual memory management, and long synchronous calls can block the UI thread.

If you are binding a library rather than calling an OS SDK, a few codegen tools sit on top of FFI instead: ffigen for C headers, jnigen for Java and Kotlin, and flutter_rust_bridge for a Rust core.

Pigeon’s runtime cost equals a hand-written MethodChannel, because under the hood it uses the same StandardMessageCodec. So the real performance choice is platform channels versus dart:ffi. When channel cost matters, it is usually because of call frequency rather than payload size. A host handler runs on the platform’s main thread by default, so heavy per-call work on a hot path eats the frame budget.

Recent Flutter raises the stakes. Flutter 3.29 merged the UI and platform threads by default on iOS and Android, and Flutter 3.35 did the same on macOS and Windows. A synchronous host handler now runs on the very thread that renders your frames. Pigeon gives you two levers. @TaskQueue(type: TaskQueueType.serialBackgroundThread) moves the native handler off the main thread, and a background isolate moves the Dart-side work off the UI thread.

That merge is also groundwork for where Flutter is heading. The team’s stated direction is direct native interop, where jnigen and swiftgen generate Dart bindings that call Kotlin and Swift APIs synchronously with no channel at all, and Dart build hooks now bundle native code without per-platform build files. jnigen is usable today and swiftgen is still experimental. For a typed contract you own on both sides, Pigeon remains the production path.

Testing the native boundary

The Dart side of Pigeon is plain, mockable Dart, so most testing happens with no platform involved. A @HostApi() generates a concrete Dart class whose methods all return a Future, even synchronous ones. The boundary design we recommend is a thin hand-written facade that takes the generated API through its constructor and translates generated types and errors into your own domain. Then you test the facade with a mock.

class PermissionsRepository {
  PermissionsRepository(this._api);
  final MediaPermissionsHostApi _api;

  Future<bool> requestCamera() async {
    try {
      return await _api.requestCamera();
    } on PlatformException {
      return false;
    }
  }
}

class MockMediaPermissionsHostApi extends Mock
    implements MediaPermissionsHostApi {}

test('camera denial surfaces as false', () async {
  final api = MockMediaPermissionsHostApi();
  when(() => api.requestCamera())
      .thenThrow(PlatformException(code: 'denied'));
  final repo = PermissionsRepository(api);
  expect(await repo.requestCamera(), isFalse);
});

The test pins the behavior the facade owns. A native failure comes back as a domain answer instead of an unhandled PlatformException. Because every generated method returns a Future, stub values with thenAnswer rather than thenReturn. We pair this with our guide to Flutter testing. For the @FlutterApi direction you do not need the channel at all. Instantiate your Dart implementation and call its methods directly.

Limitations to know before you adopt

The contract file is a schema, not runnable Dart. It can contain only declarations, no method or function bodies, so you cannot add a convenience getter to a data class there. It can import only package:pigeon/pigeon.dart, so every data class the API touches has to be declared inside the contract and you cannot reuse your domain models. A facade that maps generated types to your own is mandatory, so budget for it from the start.

The codec is fixed. You get the StandardMessageCodec set plus your defined classes, nested types, and enums. You cannot plug in a custom codec. There is no DateTime or Duration support, so pass a timestamp as an int of epoch milliseconds and reconstruct it on each side. Inheritance is mostly unsupported. The one exception is an empty sealed parent class, and only on the Swift, Kotlin, and Dart generators, so plan on composition.

Generated code churns by design. The README favors improving generated code over consistency with previous output, so breaking changes are common and upgrading routinely means regenerating and fixing call sites. Two rules follow. Both communication sides must be generated with the same Pigeon version, since a mismatch is undefined behavior that can crash the app. And generated code should not be split across packages. Putting the generated Dart in a platform-interface package and the host code in a platform-implementation package is very likely to crash some plugin clients after updates. Keep each package’s generated pair together.

Lessons from production

These are the parts that cost us time.

Check generated files into git and gate them with CI. We run a pigeon-check job that regenerates the Dart and Swift fresh and fails the build if the result differs byte-for-byte from what is committed. If someone edits messages.dart and forgets to regenerate, CI catches it instead of a runtime crash on a device. See GitHub Actions for Flutter apps.

Format only the generated Dart, never the Swift. The pigeon-check job has no Swift formatter, so it compares the committed Swift against raw Pigeon output. Running a local Swift formatter over the file makes the committed version drift from what CI regenerates, and the gate fails on every PR. Apply dart format to the Dart output and stop there.

Wrap generated types behind a facade. Do not export the generated Pigeon types from a package’s public barrel. We keep a hand-written facade in front of them and expose our own DTOs, so the public API stays stable when the contract changes shape. This is also Pigeon’s own recommendation. See Very Good layered architecture.

Give each package one regeneration entry point. We keep a tool/generate.sh per package whose one job is to run the Pigeon codegen for that package, and the pigeons/*.dart file carries a comment pointing at it. A new engineer edits the contract, runs one script, and commits, with no memorizing flags or per-package output paths. The pattern has scaled cleanly for us across repos. More in our scalable best practices.

When to adopt Pigeon

Pigeon is the right call for almost any native surface you maintain. The generated bindings remove a class of runtime bugs a raw MethodChannel lets through, and that type safety is worth it even for one or two calls. A raw channel only stays simpler for a single throwaway call you will never touch again. For CPU-bound work or an existing C, Java, or Rust library, look at the FFI family instead.

How we use it at Very Good Ventures

Pigeon is our default for typed native surfaces. We run it in a production Flutter desktop app we built for macOS, and the lessons above come from keeping that boundary healthy release after release. We also carried those lessons into the very_good_flutter_plugin template in Very Good Templates, which ships a Pigeon contract per platform package, so a new plugin starts from a working typed boundary instead of a blank one. You can scaffold one with Very Good CLI.

About the Author

Dominik Simonik

Google Developer ExpertSenior PractitionerPlatform

Dominik Simonik is a Technical Delivery Lead at Very Good Ventures who sets a high bar, pairing clean architecture and strong Flutter skills with AI to build smarter and faster. Working in mobile since 2016, he loves helping startups turn a rough idea into a production-ready product. As a GDE for Firebase and Flutter and a GDG organizer, he mentors, speaks, and organizes events. Outside work, he's into gaming, volleyball, and a good beer.

Frequently Asked Questions

What is Pigeon in Flutter?

Pigeon is Flutter's first-party code generator for type-safe platform-channel communication. You define an API contract in a Dart file, run codegen, and get type-safe Dart bindings plus native host bindings in Swift, Kotlin, Objective-C, Java, C++, or GObject. It replaces hand-written MethodChannel string keys and manual map serialization. It is published by flutter.dev under the flutter/packages monorepo.

What is the difference between a method channel and Pigeon?

A raw MethodChannel sends a string method name and an untyped argument map, so a typo or a payload-shape change fails at runtime. Pigeon generates the channel plumbing from a typed contract, so the Dart analyzer and the native compiler catch mismatches before you ship. Even for one or two calls the type safety pays for itself the first time a rename would otherwise fail silently at runtime. A raw method channel only stays simpler for a single throwaway call you will never maintain.

What is the difference between @HostApi and @FlutterApi?

@HostApi marks methods that are implemented in native code and called from Dart. @FlutterApi marks methods that are implemented in Dart and called from native code. A bidirectional feature uses both: a @HostApi for Dart-to-native pushes and a @FlutterApi for native-to-Dart callbacks.

How do you run Pigeon code generation?

Run dart run pigeon --input pigeons/messages.dart. When the input file carries a @ConfigurePigeon annotation with PigeonOptions, the output paths come from those options, so only --input is required. You can also override output locations on the command line with flags such as --dart_out and --swift_out.

Do real Flutter plugins use Pigeon in production?

Yes. Pigeon backs the platform-channel layer of Flutter's own first-party plugins, including webview_flutter, camera, in_app_purchase, google_maps_flutter, and video_player, each carrying a pigeons/ directory and a pigeon dev_dependency. Plugins such as local_auth, image_picker, url_launcher, shared_preferences, file_selector, google_sign_in, and quick_actions follow the same pattern. Not every plugin uses it, though. Some Apple-platform plugins generate their bindings with ffigen instead.

Does Pigeon support streaming events from native to Dart?

Yes, through the @EventChannelApi annotation added in Pigeon 22.7.0. You annotate an abstract class whose methods each declare the value type being streamed, and Pigeon generates a Dart Stream you consume with await for. You declare the streamed type only, not the Stream wrapper. Event-channel generation is supported on the Swift, Kotlin, and Dart generators only.

How do you unit test the Pigeon boundary?

A @HostApi() generates a concrete Dart class whose methods all return a Future, so you mock it with a mocking library such as mocktail and inject the mock into your facade. The older generated test scaffolding, the dartTestOut option and the dartHostTestHandler annotation, was deprecated in Pigeon 26.1.0 in favor of mocking the generated class directly.

Will Flutter's direct native interop replace Pigeon?

Not yet. Flutter's long-term direction is direct native interop, where jnigen and swiftgen generate synchronous Dart bindings to Kotlin and Swift APIs with no channel at all. As of 2026 jnigen is usable and swiftgen is still experimental, so platform channels remain the recommended path for typed platform-SDK work and Pigeon remains the production way to define them.

Is Pigeon faster than a hand-written method channel?

No, and that is not the point. Pigeon uses the same StandardMessageCodec as a hand-written channel, so its runtime cost is effectively equal to a correctly written manual channel. The real performance decision is platform channels versus dart:ffi, which runs synchronously on the same thread with no serialization. Pigeon's value is type safety and removing hand-rolled serialization mistakes. It does not lower latency. When channel cost matters, it is usually because of call frequency on the main thread, which you mitigate with @TaskQueue(type: TaskQueueType.serialBackgroundThread) on the native side or a background isolate on the Dart side.