Migrating from dfx to icp-cli

dfx is deprecated. Its successor is icp-cli (icp), and icpp-pro 5.5.0 and higher use icp-cli exclusively. Use 5.5.1 or later - see the note on query methods below.

This page is for developers with an existing icpp-pro project built against dfx. If you are starting fresh, follow Installation and Getting Started instead — they already use icp-cli.

Scope

This page covers what changes in an icpp-pro project. For icp-cli itself — the full command reference, recipes, environments — see the official documentation at cli.internetcomputer.org and the dfinity/icskills agent skills, which stay current as icp-cli evolves.

Behaviour below was verified against icp-cli 1.2.0.

The short version

dfx icp-cli
project file dfx.json icp.yaml
canister IDs canister_ids.json .icp/data/mappings/<env>.ids.json
target selection — CLI --network local\|ic --environment local\|ic (-e)
target selection — pytest --network local\|ic --network local\|ic — unchanged, see below
local network one global replica one per project
JS bindings dfx generate didc bind (optional)
smoketest arg dfx_json_path= icp_yaml_path=

-e vs --network — two different programs

Both flags survive the migration, but they belong to different tools:

  • icp CLI → always -e / --environment, naming an entry under environments: in icp.yaml. This guide uses -e everywhere.
  • pytest → still --network, spelled exactly as with dfx. The flag name did not change, but its meaning did: it now names an environment in icp.yaml.

So pytest --network=local and icp canister call … -e local select the same target.

(Some icp commands also accept -n / --network, which takes a network name or a raw URL and conflicts with -e. You do not need it for this migration — stick to -e.)

Your Python test code barely changes: every icpp.smoketest function kept its name and signature. The two things that do change are the Candid text your assertions compare against, and where your canister IDs live.

1. Install icp-cli & create an identity

npm install -g @icp-sdk/icp-cli
icp --version    # must be >= 1.2.0

Unlike dfx, icp-cli does not create a default identity for you, and it keeps identities in a separate keyring — your dfx identities are not visible to icp:

# create a fresh one
icp identity new default --storage plaintext
icp identity default default

# or bring an existing dfx identity across
dfx identity export my-identity > /tmp/my-identity.pem
icp identity import my-identity --from-pem /tmp/my-identity.pem
rm /tmp/my-identity.pem

Prefer icp <cmd> --identity <name> over switching the global default — it applies to that one command, so there is nothing to reset afterwards.

2. Replace dfx.json with icp.yaml

An icpp-pro canister is a pre-built wasm: icpp build-wasm produces it, and icp only has to pick it up. The Candid interface travels inside the wasm, in the icp:public candid:service custom section that icpp build-wasm writes — so no separate .did entry is needed.

# icp.yaml  (replaces dfx.json)
canisters:
  - name: my_canister
    build:
      steps:
        - type: pre-built
          path: build/my_canister.wasm

networks:
  - name: local
    mode: managed
    gateway:
      port: 0        # let the OS pick a free port — see section 5

environments:
  # these names are what you pass as `icp ... -e local|ic`
  # and as `pytest --network=local|ic`  (yes, pytest still spells it --network)
  - name: local
    network: local
  - name: ic
    network: ic

Check it parses:

icp project show

Then delete dfx.json — but not before doing section 3.

3. Migrate your canister IDs — do this before deleting anything

This step prevents orphaning a live canister

icp-cli does not read canister_ids.json. If you delete it without migrating, the next icp deploy -e ic creates a brand-new canister instead of upgrading your existing one — leaving your deployed canister orphaned and its cycles stranded.

Convert each entry. Note canister_ids.json is keyed canister → network, while icp's store is one file per environment, keyed canister → id:

// old: canister_ids.json
{ "my_canister": { "ic": "aaaaa-bbbbb-ccccc-ddddd-cai" } }
// new: .icp/data/mappings/ic.ids.json
{ "my_canister": "aaaaa-bbbbb-ccccc-ddddd-cai" }
mkdir -p .icp/data/mappings
# ...write the file as above...
rm -f canister_ids.json
git add .icp/data/          # this MUST be committed

Verify by calling the canister by name — that proves the mapping resolves:

icp canister status my_canister -e ic --id-only     # prints the id, works offline
icp canister call my_canister <a_query_method> '()' -e ic --query

4. .gitignore

Only the cache is disposable. .icp/data/ holds the mainnet canister-ID mappings you just created and must be committed:

# icp-cli
**/.icp/cache/

Use **/, not .icp/cache/

A gitignore pattern containing a slash is anchored to the directory of the .gitignore file. If your canisters live in subdirectories (canisters/foo/, test/canisters/…), a bare .icp/cache/ at the repo root silently matches nothing, and every canister's local replica state gets committed.

Confirm both directions:

# must match
git check-ignore -v path/to/canister/.icp/cache/networks

# must NOT match
git check-ignore -v path/to/canister/.icp/data/mappings/ic.ids.json

Never gitignore .icp or .icp/data wholesale.

Never rm -rf .icp - only .icp/cache

.icp/data/mappings/ holds your mainnet canister ids. Deleting it orphans the deployed canisters on the next icp deploy. This is easy to do by accident: a cleanup like

# WRONG - takes data/ with it
find . -name '.icp' -type d -exec rm -rf {} +

removes the whole tree. Clean only the cache:

# safe
rm -rf .icp/cache

5. The local network

icp-cli runs one local network per project, so projects no longer share a single global replica.

icp network start --background
icp deploy --environment local --yes
icp network stop

Set gateway.port: 0 (as in the icp.yaml above). The gateway otherwise defaults to a fixed port 8000, so two projects still collide on it. With port: 0 the OS assigns a free ephemeral port and projects — or several agents — can run side by side.

The consequence is that the port changes on every start. Never hardcode it; read it back:

icp network status --environment local --json    # -> .gateway_url / .api_url

There is no --clean flag. A managed network keeps its replica state and its canister-ID mappings under .icp/cache, so a clean start is:

icp network stop && rm -rf .icp/cache && icp network start --background

6. Update your tests

icpp.smoketest and icpp.conftest_base kept every function name, signature and fixture, so most test files need only a path change:

# before
DFX_JSON_PATH = Path(__file__).parent / "../dfx.json"
response = call_canister_api(dfx_json_path=DFX_JSON_PATH, ...)

# after
ICP_YAML_PATH = Path(__file__).parent / "../icp.yaml"
response = call_canister_api(icp_yaml_path=ICP_YAML_PATH, ...)

dfx_json_path= still works for one release as a deprecated alias (it resolves to the sibling icp.yaml and emits a DeprecationWarning), so your suite will not break the moment you upgrade.

Run pytest from your project directory so icp can find icp.yaml.

The pytest flag is still --network — do not change it to -e. That is the icp CLI's flag; pytest keeps its own spelling. What changed is only what the value refers to: an environment in icp.yaml instead of a dfx network.

pytest --network=local     # unchanged from dfx; `local` is now an icp.yaml environment

The one real breaking change: Candid text formatting

icp's Candid pretty-printer differs from dfx's. Scalars are byte-identical, so the majority of assertions are untouched:

"(true)"        # same
"(0 : int)"     # same
'("some text")' # same

Composite values differ:

dfx icp-cli
unit response (nothing) ()
vec (vec { 1; 2;}) (vec { 1; 2 }) — no trailing ;
record / long string one line wrapped over indented lines, trailing ,

icpp-pro 5.5.0 ships a helper so your assertions do not depend on that line wrapping:

from icpp.smoketest import flatten_candid_text

assert flatten_candid_text(response) == '( record { greeting = "Hello!"; }, )'

flatten_candid_text collapses runs of whitespace, so do not use it when the whitespace inside a string is what you are testing.

For reference, migrating icpp-pro's own suite touched 7 of about 130 assertions.

Query methods are auto-detected

dfx inspected the canister's Candid interface and sent a query request for methods declared query. call_canister_api does the same: it resolves the interface (from the .did next to your wasm, falling back to the canister's candid:service metadata) and picks query or update automatically. You do not have to do anything.

Pass query= only to override it:

call_canister_api(..., query=True)    # force a query request
call_canister_api(..., query=False)   # force an update request

If you used icpp-pro 5.5.0

5.5.0 always sent update requests unless you passed query=True. That is semantically correct - update requests work for query methods too - but roughly 2x slower, and it breaks tests that poll a counter or assert on timing. 5.5.1 restores the dfx behaviour, so upgrade to 5.5.1 or later and remove any query=True you added purely to work around it.

7. Javascript bindings

icp-cli has no equivalent of dfx generate. icpp build-wasm --generate-bindings yes (the default) now uses didc — the same Candid code generator dfx used internally — to write <canister>.did.js and <canister>.did.d.ts into src/declarations/<canister>/.

If didc is not installed the step is skipped with a notice. Install it (see Installation), or pass --generate-bindings no.

8. If you run CI

icp network start downloads the network launcher from the GitHub API. Unauthenticated that is 60 requests/hour shared across all GitHub-hosted runners, so CI jobs fail at random with 403 rate limit exceeded. icp-cli reads a token and authenticates with it:

jobs:
  my-job:
    runs-on: ubuntu-latest
    env:
      ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Command reference

dfx icp-cli
dfx start --clean --background icp network stop && rm -rf .icp/cache && icp network start --background
dfx stop icp network stop
dfx ping local icp network ping -e local
dfx info webserver-port icp network status -e local --jsongateway_url
dfx deploy icp deploy --environment local --yes
dfx deploy --network ic icp deploy --environment ic --yes
dfx canister call C m '(args)' icp canister call C m '(args)' -e local (add --query for a query)
dfx canister id C icp canister status C -e local --id-only
dfx canister status C icp canister status C -e local
dfx identity whoami icp identity default
dfx identity use NAME icp identity default NAME
dfx identity get-principal icp identity principal
dfx generate didc bind <did> --target js

Two differences worth knowing:

  • icp canister call requires the arguments. dfx canister call c greet becomes icp canister call c greet '()' -e local.
  • Update calls do not prompt for confirmation, so nothing needs piping into them.

Checklist

  • [ ] icp --version >= 1.2.0, and a default identity exists
  • [ ] icp.yaml per canister; icp project show parses it
  • [ ] canister IDs moved to .icp/data/mappings/<env>.ids.json and committed
  • [ ] canister_ids.json and dfx.json deleted
  • [ ] .gitignore has **/.icp/cache/, and git check-ignore confirms .icp/data is not ignored
  • [ ] tests use icp_yaml_path=, composite assertions use flatten_candid_text()
  • [ ] pytest --network=local green
  • [ ] nothing hardcodes a local port