# 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.

- Source: https://verygood.ventures/blog/flutter-pigeon-type-safe-platform-channels/
- Published: 2026-07-23
- Author: Dominik Simonik
- Tags: Flutter, Dart, Best Practices, Open Source, Multi-Platform, Architecture

---

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.

```dart
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.

```dart
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.

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

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

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

On Android you get the matching Kotlin interface.

```kotlin
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.

```dart
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.

```swift
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.

```dart
@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`.

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

Dart implements the abstract class and registers it once.

```dart
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`.

```dart
@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](https://github.com/flutter/packages/tree/main/packages/pigeon) 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.

| Tool | Best for | Tradeoffs |
| --- | --- | --- |
| Raw `MethodChannel` / `EventChannel` | A single throwaway native call with primitive data. | Not type-safe. Names and argument shapes are matched by hand, so mismatches surface at runtime. |
| Pigeon | Any 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:ffi` | Calling 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](https://docs.flutter.dev/release/breaking-changes/macos-windows-merged-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](https://blog.flutter.dev/flutters-path-towards-seamless-interop-4bf7d4579d9a), 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.

```dart
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](/blog/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](/blog/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](/blog/very-good-flutter-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](/blog/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](https://github.com/VeryGoodOpenSource/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](/blog/generate-flutter-plugins-with-very-good-cli/).
