For AI agents: Documentation index at /llms.txt

Skip to content

Inter-canister Calls

Canisters on the Internet Computer communicate by calling each other’s functions. A caller canister sends a request message containing the method name, arguments, and optionally attached cycles. The callee executes the method and returns a response. If the callee cannot be reached or a resource limit is hit, the system produces a reject response instead.

This guide covers making inter-canister calls in both Motoko and Rust, choosing between query and update calls, handling errors, and avoiding common pitfalls. For the messaging model behind these calls, see Canisters.

Query vs update calls

There are two types of canister methods, and the choice affects latency, cost, and trust:

QueryUpdate
Latency~200ms~2s
Cycle costFreeCosts cycles
ExecutionSingle replicaFull consensus
State changesNot persistedPersisted
Trust modelCaller trusts one replicaReplicated and verifiable

Use query calls when reading data where the caller trusts the subnet (or will verify the response independently). Queries are fast and free, but the response comes from a single node and is not replicated.

Use update calls when modifying state, transferring cycles, or when the caller needs a consensus-backed guarantee that the call was executed correctly.

To make query responses verifiable without the cost of update calls, see Certified Variables. For running multiple query calls in parallel, see Parallel inter-canister calls.

Making calls

Import another canister by name and call its methods with await:

Note: The canister:name import syntax is being redesigned for icp-cli compatibility. See Canister discovery for the recommended environment variable approach.

import Counter "canister:counter";
persistent actor {
public func getCount() : async Nat {
await Counter.get()
};
public func incrementAndGet() : async Nat {
await Counter.increment();
await Counter.get()
};
};

Error handling

Wrap inter-canister calls in try/catch to handle rejects:

import Counter "canister:counter";
import Error "mo:core/Error";
import Result "mo:core/Result";
persistent actor {
public shared ({ caller }) func safeIncrement() : async Result.Result<Nat, Text> {
try {
await Counter.increment();
let count = await Counter.get();
#ok(count)
} catch (e) {
#err("Counter call failed: " # Error.message(e))
};
};
};

In Motoko, public shared ({ caller }) binds the original caller at method entry, so caller remains valid after await points.

Cleanup with finally

Use try/finally (with or without catch) to guarantee cleanup code runs: even if code after an await traps. This is useful for releasing locks or rolling back temporary state:

var locked = false;
public shared func guarded() : async () {
assert not locked;
locked := true;
try {
await Counter.increment();
// ... more work
} finally {
locked := false; // Always runs, even on trap after await
};
};

The finally block must be effect-free: no await, no throw, no async calls. It must return () and should not trap: a trapping finally block can prevent future upgrades.

Canister discovery

Before making an inter-canister call, your canister needs the Principal of the target canister. Canister IDs are assigned at deployment time and differ between environments (local, staging, mainnet), so hardcoding them creates portability problems.

icp deploy automatically injects PUBLIC_CANISTER_ID:<name> environment variables into every canister in the environment. This means each canister can discover any other canister’s ID at runtime without hardcoding:

import Runtime "mo:core/Runtime";
import Principal "mo:core/Principal";
let ?counterIdText = Runtime.envVar<system>("PUBLIC_CANISTER_ID:counter") else {
return #err("counter canister ID not set");
};
let counterId = Principal.fromText(counterIdText);

Deployment order does not matter: icp deploy creates all canisters first, then injects variables, then installs code. Variables are only updated for the canisters being deployed, so run icp deploy (without arguments) when adding new canisters to update all of them.

Tip: For Rust canisters that make inter-canister calls, ic-cdk-bindgen can generate type-safe call stubs from .did files: so you call typed functions instead of manually constructing Call::unbounded_wait with string method names. See Binding generation for details.

Alternative approaches

Init arguments. Accept the target Principal as an #[init] argument and store it. This avoids the environment variable lookup at call time but requires passing the ID at every deploy and upgrade:

Terminal window
TARGET_ID=$(icp canister id counter)
icp deploy my_canister --argument "(principal \"$TARGET_ID\")"

Hardcoded principal. Acceptable for well-known system canisters (like the management canister aaaaa-aa or the NNS ledger). Avoid for application canisters.

Motoko named imports: Motoko’s import Counter "canister:counter" syntax resolves canister IDs at compile time. This syntax is currently being redesigned to work with icp-cli’s environment-based discovery model. Use environment variables for now if you are building with icp-cli.

Bounded vs unbounded wait

Every inter-canister call must choose a wait strategy. By default, calls use unbounded wait: the caller waits indefinitely until the callee responds. Bounded wait (also called best-effort messaging) adds a timeout: if the callee hasn’t responded by the deadline, the system returns a SYS_UNKNOWN response.

By default, await uses unbounded wait. Add a timeout parenthetical (in seconds) to use bounded wait:

// Unbounded wait (default): guaranteed response
let result = await Counter.get();
// Bounded wait: best-effort response with 25-second deadline
let result = await (with timeout = 25) Counter.get();
// Reusable timeout configuration
let boundedWait = { timeout = 25 };
let result = await (boundedWait) Counter.get();
// Combine timeout with cycles
let result = await (boundedWait with cycles = 1_000_000) Counter.get();

When to use each:

  • Unbounded wait: the callee is guaranteed to respond (including rejects). Use for calls to canisters you control and trust to respond promptly.
  • Bounded wait: the caller may receive SYS_UNKNOWN after the timeout or if the subnet runs low on resources. Use for calls to third-party or untrusted canisters.

Upgrade safety: unbounded wait calls may prevent your canister from upgrading until the callee responds. If the callee is unresponsive or malicious, your canister could be stuck indefinitely. Prefer bounded wait when calling canisters you do not control.

Calling third-party canisters: When calling canisters outside your control, always use bounded wait and design for uncertainty. The callee may be upgraded, become unresponsive, or behave unexpectedly. Use idempotent operations where possible and provide a way to query the outcome of a call separately, so your canister can recover from ambiguous responses.

Pub/sub pattern

The publisher/subscriber pattern is a natural fit for inter-canister communication on ICP. A publisher canister maintains a list of subscribers and notifies them when events occur. Unlike traditional pub/sub systems, ICP’s reliable message delivery means subscribers are guaranteed to receive notifications (as long as both canisters have sufficient cycles).

Publisher

The publisher stores subscriber callbacks and invokes them when publishing:

import List "mo:core/List";
persistent actor Publisher {
type Event = { topic : Text; value : Nat };
type Subscriber = {
topic : Text;
callback : shared Event -> ();
};
var subscribers = List.empty<Subscriber>();
public func subscribe(subscriber : Subscriber) {
List.add(subscribers, subscriber);
};
public func publish(event : Event) {
for (sub in List.values(subscribers)) {
if (sub.topic == event.topic) {
sub.callback(event);
};
};
};
};

Subscriber

The subscriber registers a callback with the publisher using an inter-canister call:

import Publisher "canister:pub";
persistent actor Subscriber {
type Event = { topic : Text; value : Nat };
var count : Nat = 0;
public func init(topic : Text) {
Publisher.subscribe({
topic;
callback = onEvent;
});
};
public func onEvent(event : Event) {
count += event.value;
};
public query func getCount() : async Nat { count };
};

The key mechanism is passing a shared function reference (callback) across canisters. When the publisher calls sub.callback(event), it makes an inter-canister call back to the subscriber.

Note: The subscriber uses canister:pub to import the publisher. See Canister discovery for the note on this syntax and the recommended environment variable alternative.

Important caveats

2 MB payload limit

Request and response payloads are each limited to 2 MB. For larger data transfers, chunk the payload across multiple calls.

Non-atomic execution across await

Update methods that make inter-canister calls are not executed atomically. Code before await runs as one atomic message; code after await runs as a separate message. If the callback traps after await:

  • State changes made before await are persisted
  • State changes made after await are rolled back

This means a trap in your callback does not undo work done before the call. Design accordingly: use idempotent operations and check postconditions.

Caller identity across await (Rust)

In Rust, ic_cdk::api::msg_caller() returns the caller of the current message, not the original ingress caller. After an await, the “caller” is the callee returning a response. Always bind the caller to a local variable before the first await:

#[update]
pub async fn transfer(ledger: Principal, to: Principal, amount: Nat) -> Result<(), String> {
let caller = ic_cdk::api::msg_caller(); // Bind BEFORE await
Call::unbounded_wait(ledger, "transfer")
.with_arg(&(caller, to, amount))
.await
.map_err(|e| format!("Transfer failed: {:?}", e))?;
ic_cdk::println!("Transfer initiated by {}", caller); // Safe: captured before await
Ok(())
}

In Motoko, public shared ({ caller }) captures the original caller at method entry, so this issue does not apply.

Reentrancy

Inter-canister calls are not atomic, which creates reentrancy risks. Between your outgoing call and the callback, other messages (including calls from the same canister) can execute and modify state. This can lead to double-spending or other inconsistencies.

Mitigate with locking patterns: set a flag before the call, clear it in the callback. For detailed guidance, see Inter-Canister Call Security.

canister_inspect_message does not apply

The canister_inspect_message hook is only called for ingress messages (calls from external users). It is not called for inter-canister calls. Do not rely on it for access control between canisters: perform authorization checks inside the method body instead.

Cross-subnet latency

Calls between canisters on the same subnet complete within a single round. Cross-subnet calls require 2-3 consensus rounds and are noticeably slower. Keep this in mind when designing multi-canister architectures.

Next steps