← BACK TO THE WIRE
N°0445Internet Computer8 MIN5 SOURCES

Motoko 2.0 tutorial: write and test your first canister

Pin moc 2.0.0, icp 1.6.0 and mops 3.4.2. Write a counter actor with query and update methods, a Result type and transient state. Then build it, deploy it and call it on a local network.

Motoko 2.0.0 is the current Motoko compiler in October 2026, and in it every actor keeps its variables across upgrades by default. This guide is for developers new to Motoko: by the end you will have a counter canister written for moc 2.0.0, built with icp 1.6.0 and mops 3.4.2, that you deploy and call on a local network.

What Motoko is for

Motoko is the language DFINITY designed for Internet Computer canisters. The official overview describes it as "designed for AI agents building backends for apps and services on the Internet Computer". People write it by hand too. It compiles straight to WebAssembly, handles Candid serialization for you, and models each canister as an actor. The Hello, world page puts it this way: "In Motoko, an actor is an object capable of maintaining state and communicating with other entities via message passing."

Three features matter for a first canister:

  • Query and update methods. Update methods can change state. In the docs' words, "Query methods are read-only and return information from the canister without changing its state."
  • Orthogonal persistence. Actor variables survive upgrades without serialization code or upgrade hooks.
  • Strong types. Variants, options and generics catch mistakes at compile time, such as using the wrong integer type as an index.

If you already have Motoko code from before 2.0 and need to upgrade it, skip this tutorial and follow our step-by-step Motoko 2.0.0 migration guide or the official moc v2 migration document.

Prerequisites and pinned versions

You need Node with npm to install the tools, and a Linux or macOS shell. The steps below apply to these versions:

ToolVersionWhat it does here
icp (icp-cli)1.6.0Builds the project, starts the local network, deploys and calls canisters
icp-network-launcher16.0.0-2026-10-02-03-31Runs the local replica that icp starts
ic-wasm0.11.1Post-processes the Wasm module during the build
moc2.0.0The Motoko compiler
mops3.4.2Motoko package manager and toolchain pinning

The official Motoko overview page shows a mops example that pins an older compiler. For this guide, pin 2.0.0 as shown in step 2.

If you don't want to install anything yet, ICP Ninja runs Motoko projects in the browser. You can paste the actor from step 3 into it. The rest of this guide uses the local toolchain.

The steps at a glance:

  1. Install icp-cli, ic-wasm and mops, then check their versions.
  2. Write the project config and the mops manifest, then install packages.
  3. Write the actor and type-check it with moc.
  4. See the Nat64 indexing error so you recognise it later.
  5. Build the canister with icp.
  6. Start a local network, deploy, and call the methods.

Step 1: Install the tools and check versions

The first command installs icp-cli and ic-wasm from the icp-sdk npm scope, then installs mops. This step is marked Not run on the page because installing global tools needs network access. Run it on your own machine or in your CI image with internet access.

sh · not run
npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm && npm install -g ic-mops
Not run— Installing global tools needs the network, which is unavailable in this run.

Next, confirm that the versions match the table above. The command prints the version of the CLI, the compiler and the package manager.

sh
icp --version && moc --version && mops --version
Output · exit 0 · 0.6 s
icp 1.6.0
Motoko compiler 2.0.0 (source qyfd59bi-20ns70k0-wsgga437-asc33x9r)
CLI 3.4.2
API 1.3

The output shows "icp 1.6.0" and "Motoko compiler 2.0.0". The mops lines show CLI 3.4.2 and API 1.3. If your moc is older, the M0217 warning in step 3 will not appear, and code written for 2.0 may behave differently on upgrades.

Step 2: Write the project files

icp-cli reads the project config file at the project root. This one declares a single canister named backend. It is built with the Motoko recipe at version v4.0.0, uses src/main.mo as its entry point and takes no install arguments.

yaml · icp.yaml
canisters:
  - name: backend
    recipe:
      type: "@dfinity/motoko@v4.0.0"
      configuration:
        main: src/main.mo
        args: ""

The mops manifest pins the compiler and the standard library. The toolchain section pins moc to 2.0.0, so every machine that builds the project uses the same compiler. The dependencies section pulls in core 2.6.2, the standard library that replaces the older base package.

toml · mops.toml
[toolchain]
moc = "2.0.0"

[dependencies]
core = "2.6.2"

Now install the packages. This step is also marked Not run on the page because downloading the core package needs network access. On your machine it fetches core and the pinned compiler.

sh · not run
mops install
Not run— Downloading the Motoko package requires network access, which is unavailable in this run.

To see what mops resolved, list the package sources and the path of the pinned compiler:

sh
mops sources && mops toolchain bin moc
Output · exit 0 · 1.1 s
--package core .mops/core@2.6.2/src
~/.cache/mops/moc/2.0.0/moc

The first line, "--package core .mops/core@2.6.2/src", is the flag mops passes to moc so that imports starting with mo:core resolve to version 2.6.2. The second line shows the pinned compiler at ~/.cache/mops/moc/2.0.0/moc. The Motoko recipe in the project config uses these sources during the build.

Step 3: Write the actor

This actor shows the four things the forum asks about most: query and update methods, a typed error, persistent and transient state, and caller information.

motoko · src/main.mo
persistent actor Counter {
  type Result = {
    #ok : Nat;
    #err : Text;
  };

  var count : Nat = 0;
  transient var lastAmount : Nat = 0;

  public query func getCount() : async Nat {
    count
  };

  public query func getLastAmount() : async Nat {
    lastAmount
  };

  public func add(amount : Nat) : async Result {
    if (amount == 0) {
      return #err("amount must be positive");
    };
    count += amount;
    lastAmount := amount;
    #ok(count)
  };

  public shared query(msg) func whoAmI() : async Principal {
    msg.caller
  };
};

What each part does:

  • Result is a variant type with #ok carrying a Nat and #err carrying Text. Returning it lets callers handle a bad input without the call failing. The core library also has a generic Result module you can use instead of writing your own.
  • count is a normal actor variable. In moc 2.0.0 it survives upgrades without any extra keyword.
  • lastAmount is marked transient. A transient variable is reset to its initial value, here 0, on every upgrade. Use it for caches and other data you can rebuild.
  • getCount and getLastAmount are query methods. They are fast and read-only.
  • add is an update method. It rejects 0 with an #err before touching state, and otherwise updates both variables.
  • whoAmI is declared with shared query and a message argument. The message gives you msg.caller, the Principal of whoever made the call. This is how you get caller information. To check access, compare it against a principal you stored. Use Principal.isAnonymous from core to reject callers that are not signed in.

Type-check the file without building:

sh
moc --check src/main.mo
Output · exit 0 · 0.2 s
src/main.mo:1.1-1.11: warning [M0217], redundant `persistent` keyword

The check passes, but moc prints warning M0217: "redundant persistent keyword". In moc 2.0.0 every actor is persistent by default, so the keyword on line 1 no longer does anything. You can delete it or keep it. The file compiles either way, and the warning only reminds you of the new default.

Step 4: The Nat64 indexing pitfall

A common forum question is why an array lookup fails when the index comes from a timestamp, a Candid field or a counter typed as Nat64. This file reproduces it:

motoko · src/nat64-pitfall.mo
actor {
  let values : [Nat] = [10, 20, 30];
  let index : Nat64 = 1;
  let selected : Nat = values[index];
};

The check fails on purpose:

sh
moc --check src/nat64-pitfall.mo
Output · exit 1 · expected · 0.2 s
src/nat64-pitfall.mo:4.31-4.36: type error [M0096], expression of type
  Nat64
cannot produce expected type
  Nat

Error M0096 points to line 4, columns 31 to 36, which is the index expression. It says an expression of type Nat64 "cannot produce expected type" Nat. Motoko arrays are always indexed with Nat, its arbitrary-precision natural number type, and the compiler never converts between number types for you. To fix it, import the Nat64 module from core and convert the value with Nat64.toNat before using it as an index. A simpler option is to keep indexes typed as Nat from the start. Use Nat64 only where the interface needs a fixed width, for example in a Candid nat64 field.

Step 5: Build the canister

With the working actor in place, build it through icp-cli. The build compiles src/main.mo with the pinned moc and post-processes the module.

sh
icp build
Output · exit 0 · 1.9 s
Building canisters:
Canisters built successfully

The output ends with "Canisters built successfully". The pitfall file is not part of the build because the project config names only src/main.mo as the entry point.

Step 6: Deploy and call the canister

This step starts a local network in the background, deploys the canister and calls it four times. The calls work as a smoke test: two good inputs, one bad input, and a read.

sh
icp network start -d && icp deploy && icp canister call backend add '(2 : nat)' && icp canister call backend add '(0 : nat)' && icp canister call backend add '(3 : nat)' && icp canister call backend getCount '()'
Output · exit 0 · 22 s · 22 lines
For background mode, network output will be redirected:
  stdout: ~/motoko-first-canister/.icp/cache/networks/local/network-launcher/stdout.log
  stderr: ~/motoko-first-canister/.icp/cache/networks/local/network-launcher/stderr.log
Network started on port 8000
Seeding ICP and cycles account balances
To stop the network, run `icp network stop`
Building canisters:
Creating canisters:
Setting environment variables:
Applying canister settings:
Checking compatibility:
Installing canisters:
No canisters have sync steps configured
Created canister backend with ID 4caro-hl777-77775-aaaba-cai
Deployed canisters:

Backends (Candid UI):
  backend: http://4mc4g-43777-77775-aaaaa-cai.localhost:8000/?id=4caro-hl777-77775-aaaba-cai
(variant { ok = 2 : nat })
(variant { err = "amount must be positive" })
(variant { ok = 5 : nat })
(5 : nat)

The output shows the following:

  • The local network started on port 8000, and the launcher writes its logs to files under the project's .icp cache folder.
  • The deploy created the backend canister with ID 4caro-hl777-77775-aaaba-cai and printed a Candid UI link for it.
  • Adding 2 returned the ok variant with 2. Adding 0 returned the err variant with "amount must be positive". Adding 3 returned the ok variant with 5.
  • getCount returned 5, so the rejected call did not change state.

The output also tells you how to stop the network when you are done. You can open the Candid UI link in a browser to call getLastAmount and whoAmI by hand. For automated tests, the ICP docs cover PocketIC and testing strategies, and mops includes a test runner for Motoko unit tests.

Pitfalls and troubleshooting

SymptomCauseFix
Warning M0217 about a redundant persistent keywordmoc 2.0.0 makes actors persistent by defaultRemove the keyword, or keep it and ignore the warning
Type error M0096, Nat64 cannot produce expected type NatArray index is not a NatConvert with Nat64.toNat or type the index as Nat
A value resets after an upgradeThe variable is marked transientRemove transient if the value must survive upgrades
An older moc builds the projectThe toolchain is not pinnedPin moc 2.0.0 in the mops manifest and reinstall
Upgrade of older code is rejectedStable state changed in an incompatible wayFollow the Motoko 2.0 migration guide linked above

Two more notes. First, trapping and returning an error are different. If an update method traps, its state changes are rolled back and the caller gets a reject. Returning #err, as add does, gives the caller a normal reply it can handle. Second, upgrade compatibility checks became stricter in recent releases. Before you change the type of a persistent variable, read our earlier coverage of the upgrade check added in Motoko 1.12.0.

FAQ

What is the Motoko language used for?

You use Motoko to write canisters, the smart contracts of the Internet Computer. It compiles to WebAssembly, generates the Candid interface, and keeps actor state across upgrades without hand-written serialization. Typical uses are app backends, tokens and services that call other canisters.

What does the transient keyword do in Motoko?

In moc 2.0.0, actor variables are persistent by default and survive upgrades. Marking a variable transient opts it out: it goes back to its initial value after each upgrade. Use it for caches, counters you can rebuild, or anything you do not want to keep.

Why can't I index an array with a Nat64 in Motoko?

Array indexes must be Nat, and Motoko does not convert between number types on its own, so a Nat64 index fails with error M0096. Convert it with Nat64.toNat from the core library, or keep indexes typed as Nat.

TAGSMotoko 2.0icp-climopsorthogonal persistence
Grounded sources5 REFS
  1. [01]Hello, world! (Motoko) | ICP Developer Docsdocs.internetcomputer.org ↗
  2. [02]Motoko overview | ICP Developer Docsdocs.internetcomputer.org ↗
  3. [03]caffeinelabs/motoko repositorygithub.com ↗
  4. [04]Motoko 2.0.0 releasegithub.com ↗
  5. [05]moc v2 migration guidegithub.com ↗
Read next

Get the wire in your inbox

Every new signal, straight from the generator. No noise, unsubscribe anytime.

RSS AVAILABLE · NO SPAM