Pigeon: Type-Safe Platform Channels Without the Boilerplate
How the Pigeon code generator replaces hand-written MethodChannel code with type-safe Dart-defined APIs for Kotlin, Swift, Objective-C, Java, C++, and Linux — with async calls, error handling, and when to prefer FFI.
Published on • September 25, 2026
AI Assistant

Hand-written platform channels are stringly-typed plumbing. You invent a channel name, invent method names as strings, encode arguments into maps by hand, decode them on the other side in Kotlin or Swift, cast each field back to its type, and hope both halves stay in sync. One typo in a method name, one wrong cast, and you find out at runtime — in a code path you only tested on one platform.
Pigeon, published by the Flutter team itself, removes that entire class of bug. You declare your API once, in Dart, and Pigeon generates the matching typed code for every host platform: Kotlin, Java, Swift, Objective-C, C++ for Windows, and GObject for Linux. As the package puts it, it “removes the need to write custom platform channel or native interop code, since Pigeon generates it for you.”
The current release is v29.0.4, pulling around 666,000 weekly downloads.
How It Works
The workflow has four steps:
- Add
pigeonas a dev dependency. - Create a
.dartfile outside yourlibdirectory — say,pigeons/api.dart— containing only declarations. No method bodies; this file is an interface definition, not implementation. - Run
flutter pub get, thendart run pigeonwith options pointing at input and output paths. - Copy the generated Dart code into
lib/, and add the generated host code to each platform project (the Xcode project for iOS/macOS,android/app/src/main/javafor Android,windows/plus CMakeLists for Windows, and the Linux GObject sources).
From then on, changing the API means editing the one Dart file and regenerating — every platform updates in lockstep.
Defining an API
APIs are abstract classes annotated with @HostApi() (implemented natively, called from Dart) or @FlutterApi() (implemented in Dart, called from the host):
import 'package:pigeon/pigeon.dart';
class SearchRequest {
String query = '';
int limit = 10;
}
class SearchReply {
List<String>? results;
String? error;
}
@HostApi()
abstract class Api {
SearchReply search(SearchRequest request);
@async
Future<Data> fetchData(String id);
}
Custom classes, enums, nested types, generics, and even top-level constants (const int anIntConstant = 42;) are supported, as is limited inheritance — “basic inheritance with empty sealed parent classes” works for Swift, Kotlin, and Dart. Because Pigeon builds on the standard StandardMessageCodec, anything platform channels can carry, Pigeon can carry, but with generated serialization instead of hand-built maps.
Naming can be tuned per platform: @ObjCSelector and @SwiftFunction adjust generated selector names to fit native conventions.
Asynchronous Calls
Native work is usually slow — file I/O, biometrics, sensors — so Pigeon offers two async flavors:
@async— modern concurrency signatures: Kotlinsuspendfunctions, Swiftasyncfunctions. The default recommendation.@asyncCallback— completion-callback style for APIs where coroutines/async don’t fit:(Result<T>) -> Unitin Kotlin, completion closures in Swift.
Only the Kotlin and Swift generators distinguish the two; Java, Objective-C, C++, and GObject generators treat both as callback-based. Fully synchronous native methods are also allowed — but even then, calls remain asynchronous from the Dart side, so the UI thread never blocks on the host.
Error Handling
Errors cross the boundary in both directions as typed exceptions. On the host side you signal failure with FlutterError (Kotlin) or PigeonError (Swift); Pigeon translates these into PlatformExceptions on the Flutter side for both sync and @async methods, while @asyncCallback methods deliver failures via Result.failure(...):
// Generated Kotlin host implementation
override fun search(request: SearchRequest, callback: (Result<SearchReply>) -> Unit) {
try {
val reply = performSearch(request)
callback(Result.success(reply))
} catch (e: Exception) {
callback(Result.failure(FlutterError("search_failed", e.message, null)))
}
}
// Dart side
try {
final reply = await api.search(SearchRequest(query: 'flutter', limit: 5));
} on PlatformException catch (e) {
// e.code == 'search_failed'
}
On Java, sync throws are caught automatically and async errors flow through the callback; Objective-C signals errors through the error argument; C++ returns a FlutterError.
Platform Channels or Native Interop?
Since recent versions, Pigeon also supports an experimental native interop mode — direct FFI/JNI calls that skip the platform channel machinery entirely. The trade-offs the package documents:
Choose platform channels (the default) when you target Windows or Linux, when the calls are simple or low-frequency, or when you want the simplest setup story. TaskQueue threading control is also platform-channels-only — it is a code-generation error under native interop.
Choose native interop for high-frequency messaging, large typed arrays, or latency-sensitive communication. FFI calls are synchronous, low-latency, skip serialization overhead, and — importantly — can be executed directly from background isolates, which matters as Dart’s broader concurrency story grows.
For most plugins today, platform channels remain the right default; the interop mode is a scalpel for hot paths.
Caveats Worth Knowing
- Version lockstep: both sides of the boundary must be generated by the same Pigeon version. Mismatched versions have “undefined behavior, including potentially crashing the application” — so commit generated code deliberately and upgrade Pigeon as a coordinated change.
- Generated code is not a stable API: Pigeon favors improvement over backward compatibility, and breaking changes across versions are common. Avoid exposing Pigeon-generated types in your package’s public API, and don’t split generated code across packages.
- The input file must stay declaration-only — Pigeon parses it as a schema, not as runnable code.
Pigeon vs Hand-Written MethodChannel
| MethodChannel | Pigeon | |
|---|---|---|
| Type safety | Runtime casts, your responsibility | Compile-time, generated |
| Method dispatch | String names | Generated typed methods |
| Serialization | Hand-built maps | Automatic |
| Error handling | Ad hoc error codes | Translated PlatformExceptions |
| Multi-platform effort | N implementations to keep in sync | One schema, all generators |
| Boilerplate | High | Near zero after setup |
The Flutter team’s own first-party plugins use Pigeon extensively for exactly these reasons. Unless you’re calling an existing channel protocol you don’t control, there is rarely a reason to hand-write channel code in 2026.
Summary
Pigeon turns platform channels from a runtime-risk discipline into a compile-time guarantee. Define the contract once in Dart, generate Kotlin, Swift, Objective-C, Java, C++, and GObject implementations, and let mismatches become build errors instead of production crashes. With @async for modern concurrency, translated errors, TaskQueue threading, and an FFI escape hatch for hot paths, it covers the full range of native integration needs — and at 666k weekly downloads, it has quietly become the standard way Flutter talks to the platform.