Skip to content
Blog

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:

  1. Add pigeon as a dev dependency.
  2. Create a .dart file outside your lib directory — say, pigeons/api.dart — containing only declarations. No method bodies; this file is an interface definition, not implementation.
  3. Run flutter pub get, then dart run pigeon with options pointing at input and output paths.
  4. 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/java for 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: Kotlin suspend functions, Swift async functions. The default recommendation.
  • @asyncCallback — completion-callback style for APIs where coroutines/async don’t fit: (Result<T>) -> Unit in 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

MethodChannelPigeon
Type safetyRuntime casts, your responsibilityCompile-time, generated
Method dispatchString namesGenerated typed methods
SerializationHand-built mapsAutomatic
Error handlingAd hoc error codesTranslated PlatformExceptions
Multi-platform effortN implementations to keep in syncOne schema, all generators
BoilerplateHighNear 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.

References