canot

Documentation

One command, what it can do, and access for your agent. Ask the MCP agent for the details, or read them here.

CommandsLocal or on-prem exitUsing it with an agent (MCP)The scanOn-prem arrivalHosts

Canot: the commands

Canot rehearses your exit from a data platform, inside your own accounts. One command is enough:

uvx canot drill SOURCE DESTINATION

SOURCE is the platform you would leave (Snowflake, Databricks, BigQuery), or a dbt project that runs on one, DESTINATION is where the copy is written. Replace the MY_… values with your own. Canot copies the tables to Apache Iceberg, compares everything, replays your most expensive queries and writes a signed evidence log. It sends nothing to us: no telemetry.

# Snowflake to a local folder
uvx canot drill "snowflake://MY_ACCOUNT/MY_DATABASE?user=MY_USER" ./iceberg

# Databricks to the S3 storage of your choice
uvx canot drill "databricks://MY_WORKSPACE.cloud.databricks.com/sql/1.0/warehouses/MY_WAREHOUSE_ID?catalog=MY_CATALOG" "s3://my-bucket/canot?endpoint=https://s3.internal:9000&region=us-east-1"

# BigQuery to a European hosting provider
uvx canot drill "bigquery://MY_PROJECT/MY_DATASET?location=EU" scaleway:fr-par --bucket my-bucket

# A dbt project: the tables it uses are copied and compared, then its models are replayed on the copy
uvx canot drill dbt://./my_dbt_project ./iceberg --target prod

The Snowflake, BigQuery and Databricks connectors ship with Canot: nothing to add. Without uv, pipx run canot … or pip install canot work the same way.

The commands

CommandWhat it does
canot scan SOURCERead-only assessment: inventory, volumes, consumption, what does not move as-is, query replayability. Writes scan.html (to share), scan.json and CSV files. See scan.
canot drill SOURCE DESTINATIONFull exit exercise: copy, table-by-table comparison, query replay, signed evidence log. With dbt://PROJECT as the source: reads the warehouse named by the dbt profile, copies the relations the project uses, then replays its models on the copy (P1 to P3, one evidence folder). See canot drill dbt://.
canot demoA 30-second demo, no account or data needed: the TPC-H dataset (the standard database benchmark, generated locally, 100 MB by default; --sf 1 for about 1 GB) and a full exercise. It prints the two commands it runs (demo-data, drill) and accepts a destination: canot demo aws:eu-west-3 --bucket MY-BUCKET. Offline, it falls back to a fictional dataset.
canot verify-evidence FOLDERVerifies the hash chain and signature of an evidence log. --expect-pub rejects a log signed by a different key.
canot teardown DESTINATIONDeletes the copied data.
canot snowflake-setupWrites the read-only access SQL script for an administrator to review and run. --generate-key creates the key pair.
canot bigquery-setupWrites the gcloud script for the read-only custom role.
canot init-tolerancesWrites a comparison tolerances file to review together.
canot demo-data PATHCreates a demo DuckDB database.
canot replay-dbt dbt://PROJECT --base COPY.duckdbP3 evidence on a dbt project: replays the SQL models in DuckDB from the copy of the sources (a DuckDB database, or --iceberg for the copy written by canot drill), compares each output to the original table (rows, statistics, fingerprint) and signs an evidence log. See scan.
canot connectorsLists the known platforms: URL format, inventory (scan), export (drill), evidence type, status (tested, or written but untested) and the pip extra to install.
canot mcpMCP server: the Canot documentation and explanations of your reports, for an agent. See Using it with an agent.

Every command accepts --debug (full traceback on error).

canot drill

Useful options:

OptionPurpose
--bucket NAMERequired with a hosting provider shortcut (scaleway:fr-par, ovh:gra, outscale:eu-west-2, ionos:eu-central-1, stackit:eu01, aws:eu-west-3).
--catalog URIIceberg catalog: sqlite:///…, or env:VARIABLE_NAME for PostgreSQL (canot[postgres]). Default: a SQLite file in the output folder.
--out FOLDERWhere to write the report and the evidence log (default canot-out).
--tables SCHEMA.TABLE …Restrict to specific tables.
--queries FILE.sqlReplay your own queries. Default: the most expensive ones from the platform's query history.
--top NNumber of history queries to replay (default 20).
--tolerances FILE.tomlComparison tolerances (rounding, timestamps).
--credit-price USDThe Snowflake credit price in your contract. Without it: a dated range based on the list price.
--replay-max-gb NMaximum size of a table loaded in memory to replay queries (default 2).
--key PATHSigning key (default ~/.canot/evidence.key, or the CANOT_EVIDENCE_KEY_PATH variable).
--profiles-dir FOLDERdbt project only: the folder that holds profiles.yml.
--target NAMEdbt project only: the profile target to read (also dbt://PROJECT?target=NAME).

Output in --out: report.html (readable by a committee), report.json, the evidence log (evidence.jsonl, evidence.sig, evidence.pub) and reread.sql. The signature attests that the log has not been modified since it was signed. It attests neither the author nor the date.

canot drill dbt://

uvx canot drill dbt://PROJECT_PATH DESTINATION [--target NAME] [--profiles-dir FOLDER]

The exit exercise of a dbt project, in one command:

1. Reads the project: dbt_project.yml (the profile name), profiles.yml and target/manifest.json (dbt parse produces it from the project files, without connecting to the warehouse). profiles.yml is found with --profiles-dir, else DBT_PROFILES_DIR, else the project folder, else ~/.dbt/profiles.yml; the target is --target (or ?target=NAME in the URL), else the profile's default target. {{ env_var('NAME') }} and {{ env_var('NAME', 'default') }} are resolved; no other Jinja is evaluated and no code is run: a value that needs more is refused with the field named. 2. Works out the warehouse and the relations: the adapter of the target gives the platform; the manifest gives the relations, under the names dbt uses (database, schema, alias). Copied: every source, seed and snapshot, every model materialized as table or incremental (a Python model's table is copied and compared, the model is not replayed). Not copied, with the reason in the report: views (they hold no data; they are replayed and proven through the tables that read them), ephemeral models (no relation), materialized views and dynamic tables (maintained by the platform, to rebuild), relations in another database, and relations the warehouse does not show to the profile's role. 3. Copies and compares those tables to Apache Iceberg: the same copy, comparison and query replay as canot drill. 4. Replays the models on the copy and compares each output with the original table, as canot replay-dbt does: identical, different, not compared, not replayable. 5. Writes one evidence folder in --out: report.html and report.json (the drill, with a dbt section: relations copied and not copied, replay, the explanation of each difference), replay-dbt.json, one signed evidence log (canot verify-evidence checks it) and reread.sql.

AdapterConnection read from the profileCredentials
snowflakeaccount, user, role, warehouse, database; the schemas of the relationsprivate_key_path (becomes SNOWFLAKE_PRIVATE_KEY_PATH unless already set) or a password from the environment; externalbrowser and inline keys are not supported yet
bigqueryproject (execution_project for billing), locationmethod: oauth (application default credentials) or service-account (the keyfile path becomes GOOGLE_APPLICATION_CREDENTIALS unless already set)
databrickshost, http_path, catalogtoken from the environment (DATABRICKS_TOKEN)
duckdbpath (relative to the project folder)none

Another adapter (postgres, redshift, sqlserver…) gets the same message as a platform without a connector, with how to ask for one.

Your credentials, read-only. Canot connects with the credentials and the role the profile names, only reads, and never adds a right. What the role cannot see is reported as not copied, never worked around. A secret (password, token, key passphrase) is accepted only as {{ env_var('…') }}: written in clear in profiles.yml, it is refused. Secrets are never printed nor written: the evidence log records the adapter, account or project, database, role and schemas, never the user, a secret or a key path.

Reading the result. A model that differs while its inputs were copied identically is explained with what the run measured, measured causes first: an input copied with a lossy conversion (a TIMESTAMP_TZ reduced to UTC shifts the dates derived from it, see discrepancies), then the row access and masking policies on its inputs (Snowflake: read with the profile's role, or reported as not readable). Under such a policy the copy holds what the role can read: a table that dbt built with a role that saw more cannot be reproduced from it. That is the expected result for this reader, not a translation error (finding F10 of canot scan). Exit code: 0 when the copy is identical and no model differs, 1 otherwise.

Tested on 2026-10-19 on the Snowflake test bench (lab/dbt, target snowflake); BigQuery and Databricks profiles are written but not yet validated on a real account. Details: scan.

canot scan

uvx canot scan "snowflake://MY_ACCOUNT/MY_DATABASE?user=MY_USER" --level 2

--level 1|2|3 (default 3) sets how deep to read; --credit-price replaces the list price; --include-sql-text also writes the raw SQL to a separate file (never by default). Details of the reads, permissions and guarantees: scan.

The sources

SourceFormatAuthentication
BigQuerybigquery://PROJECT/dataset1,dataset2?location=EU; without a dataset: all datasets in the project; another project: bigquery://BILLING_PROJECT/data-project.datasetGoogle application default credentials (gcloud auth application-default login)
Snowflakesnowflake://ACCOUNT/DATABASE?user=…&warehouse=…&role=…&schemas=A,Bkey pair: SNOWFLAKE_PRIVATE_KEY_PATH variable (recommended) or SNOWFLAKE_PASSWORD
Databricksdatabricks://HOST/WAREHOUSE_HTTP_PATH?catalog=…DATABRICKS_TOKEN variable (read token); connector not yet tested on a real account
DuckDBhouse.duckdbnone
dbt (project)dbt://PROJECT_PATH: the project, its target/ folder or manifest.json. canot scan reads the artifacts offline; canot drill dbt://PROJECT DESTINATION (?target=NAME, --profiles-dir) reads the warehouse named by profiles.yml (Snowflake, BigQuery, Databricks, DuckDB)scan: none, no network, no dbt command. drill: the credentials of the dbt profile, read-only; secrets only from the environment

canot connectors shows, for each one, what Canot can do (full or light inventory, export, evidence) and whether it is tested. For dbt, canot drill dbt:// gives P1 to P3 in one folder; canot replay-dbt replays the models on a copy that already exists.

Local or on-prem exit

No cloud needed on arrival: a folder is enough.

uvx canot drill "snowflake://MY_ACCOUNT/MY_DATABASE?user=MY_USER" ./iceberg

Canot writes the copy in Iceberg format to ./iceberg and the evidence log to canot-out/. The copy does not depend on the tool: DuckDB, Spark or Trino can read it. The reread.sql file contains the DuckDB commands to recount each table:

duckdb < canot-out/reread.sql
DestinationCommand
Local or mounted folder (disk, NAS, network share)canot drill SOURCE /path/to/folder
Self-hosted S3-compatible storage (MinIO, Ceph, NetApp…)canot drill SOURCE "s3://bucket/canot?endpoint=https://s3.internal:9000&region=us-east-1"
Corporate proxy, internal certificate authorityadd &proxy=http://proxy.internal:3128 and &ca=/path/ca.pem to the S3 URL
PostgreSQL catalog--catalog env:CANOT_CATALOG_URI (password in the variable, never on the command line)

Storage keys go through CANOT_S3_ACCESS_KEY and CANOT_S3_SECRET_KEY (or the scw configuration). Status of each case (tested or only wired up): on-prem.

Hosting providers

scaleway:fr-par, ovh:gra, outscale:eu-west-2, ionos:eu-central-1, stackit:eu01, aws:eu-west-3, with --bucket. Check each endpoint in the provider's console. SecNumCloud qualification, when it matters to you, depends on the specific offering: see targets.

Using it with an agent (MCP)

Many teams work with an agent. canot mcp gives it the Canot documentation and explains your reports, without sending anything to anyone: the server runs on your machine.

Setup in Claude Code:

claude mcp add canot -- uvx canot mcp

Or in your client's MCP configuration (Claude Desktop, Cursor, VS Code…):

{
  "mcpServers": {
    "canot": { "command": "uvx", "args": ["canot", "mcp"] }
  }
}

Exposed tools:

ToolWhat it does
commandsLists the commands and their options.
search_docs(query)Searches the documentation and returns the matching passages.
read_doc(topic)Returns a full page: commands, scan, on-prem, targets, discrepancies.
capabilities(platform)What Canot can do on each platform (inventory, export, evidence type), with the status (tested, or written but untested) and the recorded test date. Without an argument: all platforms.
explain_scan(path)Reads a scan.json and summarizes it: findings, reads not performed, estimated figures, what to look into.
explain_report(path)Reads an exercise report.json: discrepancies, tables and queries involved, known causes from the catalog. Also reads a replay-dbt.json (dbt P3 evidence): identical, different and non-replayable models, with their reasons; for a canot drill dbt:// folder, the dbt section: relations not copied and why each model differs.

The tools read files you point them to. They open no connection to your platforms.

For agents that read the web: canot.io/llms.txt and canot.io/llms-full.txt.

Going further

  • scan: what is read, with which permissions, and the guarantees.
  • on-prem: exit to a local folder or your own storage.
  • targets: hosting providers and countries.
  • discrepancies: what two engines return differently, measured.
  • Help running it on your scope: canot.io.

canot scan: what is read, with which permissions

The scan produces scan.html (to share), scan.json and CSV files (flags.csv, families.csv, reads.csv, tables.csv). It describes your platform; it recommends neither leaving nor staying.

Guarantees

  • Read-only. (dbt project: no queries, only files are read, see dbt.) Every query goes through a guard before it is sent: only SELECT, SHOW, DESCRIBE and ALTER SESSION SET are allowed. Everything else is rejected without sending anything. SYSTEM$ functions are rejected too.
  • Never the content of your tables. The scan reads metadata and execution history. It requests no read permission on the data.
  • Raw SQL is not written. Literals are masked (WHERE email = ?), and queries that contain a password or a secret are counted, then discarded. Raw text is written only with --include-sql-text, to scan-sql-raw.json (permissions 0600), and the report says so.
  • Pseudonymized identities. Query users and roles are replaced with a pseudonym (HMAC, with a salt drawn for each scan and never written). Email addresses, login names and IP addresses are never read. Storage paths are reduced to the bucket name.
  • No read silently lost. Every read is recorded as "read" or "not read" with its reason. A value that was not read stays empty, never 0.

Levels (--level, 3 by default)

LevelContentAdditional permissions
1Inventory, column types, volumes, billed history, consumption, objects (views, routines, tasks, stages, shares…), dependenciesmetadata and consumption
2+ normalized query shapes and their replayability, reads and writes per table, policiesaccess to query history
3+ owner roles, client tools, user types, reserved capacitygrants and sessions

What your permissions do not allow is recorded as "not read" in the report (finding F12): the scan reads what it has access to and states what it did not see.

Snowflake

canot snowflake-setup --generate-key --base MY_DATABASE writes the script for an administrator to review and run. It creates a role, an XS warehouse that suspends after 60 s and a credit cap, and grants Snowflake's database roles:

Database roleLevelUsed for
SNOWFLAKE.OBJECT_VIEWER1tables, columns, views, routines, tasks, stages, dependencies
SNOWFLAKE.USAGE_VIEWER1volumes, credits, history, loads
SNOWFLAKE.GOVERNANCE_VIEWER2query history, reads per table, policies, tags
SNOWFLAKE.SECURITY_VIEWER3roles, users (name and type only), sessions

Never requested: IMPORTED PRIVILEGES, MANAGE GRANTS, IMPORT SHARE, USAGE on functions, READ on stages.

Known limits: ACCOUNT_USAGE has a latency of 45 minutes to 3 hours (up to 2 days for load history). The report shows the latency of each read. ACCESS_HISTORY requires Enterprise edition; without it, reads per table are inferred from TABLE_QUERY_PRUNING_HISTORY with medium confidence.

BigQuery

canot bigquery-setup --project MY_PROJECT --member user:… writes the gcloud script for a canotScanner custom role. Required permissions: bigquery.jobs.create, bigquery.jobs.listAll, bigquery.datasets.get, bigquery.tables.list, bigquery.tables.get, bigquery.tables.getIamPolicy, bigquery.routines.list, bigquery.routines.get, bigquery.models.list, bigquery.models.getMetadata, bigquery.reservations.list, bigquery.reservationAssignments.list, bigquery.capacityCommitments.list, bigquery.bireservations.get, bigquery.config.get. Optional: bigquery.rowAccessPolicies.list, bigquery.transfers.get, bigquery.connections.list, bigquery.dataPolicies.list.

Never: bigquery.tables.getData, nor the bigquery.user or jobUser roles.

Method notes:

  • One INFORMATION_SCHEMA query per region (regions are not hierarchical: EU does not cover europe-west1). BigQuery bills at least 10 MB per query of this type, without caching: the scan caps each query and runs a dry run first.
  • Volumes come from TABLE_STORAGE when it is readable, otherwise from the tables.get API. Canot never changes the project settings to enable it.
  • Under a row access policy, total_bytes_billed is empty: the report says so instead of showing 0.
  • The free tier is shared across the billing account: it cannot be attributed per project.

dbt

canot scan dbt://PROJECT_PATH reads the project's artifacts, with no network and no dbt command: target/manifest.json (required), plus target/run_results.json and target/catalog.json if they exist. PATH can be the project, its target/ folder or the manifest.json file; a project folder passed without dbt:// is recognized too. Without a manifest, dbt parse produces one by reading the project files, without connecting to the warehouse. A single level (1): --level is brought down to 1.

ReadWhat is kept
manifest.jsonmodels (materialization, target relation, dependencies), tests, sources, seeds, snapshots, exposures, metrics, project macros, packages (number of macros), adapter and dbt version, dependency graph (liens: every ref() and source())
Compiled SQL of models, tests, snapshotssha256 fingerprint of the normalized shape (literals masked), line count, functions and constructs specific to the adapter's dialect (sqlglot), functions missing from DuckDB, DuckDB translation category (C0 to C7, same rules as for queries); never the text
run_results.jsonstatus of the last invocation per node, failures, failing rows of tests, last write date of tables
catalog.jsoncolumns and types (skeleton for the DuckDB EXPLAIN), rows and bytes if the adapter provides them, pseudonymized owner

Portability of a transformation: traduisible (translatable) when sqlglot translates the SQL to DuckDB (accepted by EXPLAIN if catalog.json is read, "not tested" otherwise); a_reconstruire (to rebuild) only on a fact that was read: a function missing from DuckDB, an impossible translation, an adapter-specific materialization (for example dynamic_table); non_determine (undetermined) for a Python model, uncompiled SQL still full of Jinja, or SQL that contains a secret (counted, then discarded).

Findings: F13 for a transformation to rebuild (with the reason), F08 for a model whose SQL depends on Jinja and macros ({% %} blocks, calls other than ref, source, config; dbt's generic tests do not count), F11 for the project DAG (last invocation, failures; scheduling not read: it lives in the orchestrator), F01 for an external source (location reduced to the bucket), F12 for a missing artifact. Consumption, billed volumes and permissions are not in these files: the report marks them "not read".

Validated on 2026-10-19 on the test bench project lab/dbt (artifacts from a real dbt build, dbt 1.12.6, Snowflake adapter): 8 models (4 staging views, 4 mart tables), 8 tests, 4 sources, 19 links; findings F11 and F12 (no catalog.json). Copy of these artifacts and frozen outputs: tests/fixtures/dbt/banc, tests/golden/cli_scan_dbt.

P3 evidence: canot replay-dbt

canot replay-dbt dbt://PROJECT --base copy.duckdb (or --iceberg DESTINATION --drill-out FOLDER for the Iceberg copy written by canot drill) replays each SQL model in a local DuckDB, in graph order, from the source tables of the copy, then compares its output to the table the platform had materialized, using the same criteria as canot drill: row count, per-column statistics, content fingerprint. The types of the replayed output are aligned with those of the original table before the comparison; each conversion is recorded. The result (replay-dbt.json) and the signed evidence log (evidence.jsonl, to check with canot verify-evidence) classify each model:

StatusMeaning
identique (identical)replayed, compared, no discrepancy within tolerances
differentreplayed, compared, discrepancy (rows, statistics or fingerprint)
non_compare (not compared)replayed, but no original table in the copy (a view); its output feeds the downstream models
non_rejouable (not replayable)with the reason: Python language, incremental (depends on prior state), ephemeral (replayed through the models that use it), adapter-specific function or materialization, translation rejected, table missing from the copy, dependency not replayed

The compiled SQL must be in the manifest (dbt compile or dbt build puts it there). Tested on 2026-10-19 on the test bench artifacts and a DuckDB copy built by the tests (original tables written by hand, no network): 4 identical mart tables, 4 views replayed but not compared, an injected discrepancy detected, including when reading the Iceberg copy written by canot drill.

First replay on a real copy (2026-10-19): canot drill of the test bench Snowflake account (RAW and marts schemas) to a local Iceberg copy, then canot replay-dbt --iceberg, with no new query to the platform. Result: int_order_totals identical, 4 views replayed but not compared, 3 marts different (dbt_fct_orders: 150,000 rows originally, 25,325 replayed; dbt_customer_ltv; dbt_daily_revenue). These discrepancies are correct: the customers table is protected by a row access policy and the drill's read role only sees part of it. The copy of the inputs is incomplete and the replay shows it: this is exactly what P3 must detect (finding F10 of the Snowflake scan points out that a copy requires a principal not filtered by these policies).

The same run revealed a false discrepancy, since fixed: int_order_totals was classified as different although rows and statistics were equal. Cause, column by column: ORDER_ID and ITEMS equal (the recorded conversion ITEMS : HUGEINT -> INTEGER changes no value); BASKET_VALUE is a sum of floats (UNIT_PRICE as FLOAT) that Snowflake and DuckDB add up in a different order. 36,971 values differ in the last bit (121.79 versus 121.79000000000002, relative difference at most 4e-16), and the content fingerprint hashes the exact text. Now, when rows and statistics are equal and only the fingerprint differs, the replay recomputes the fingerprint on both sides with float columns rounded to 12 significant digits (finer than the relative tolerance of 1e-9). If it is then equal, the model is "identical within tolerance" and the report records the rounded columns, the number of values that differ in the last bit and the maximum measured relative difference (verification.empreinte_flottants). A larger discrepancy stays "different".

Exit exercise of a dbt project: canot drill dbt://

canot drill dbt://PROJECT DESTINATION --target NAME chains the two steps above with the copy in between: it reads profiles.yml to find the warehouse, copies the relations the manifest names (sources, seeds, snapshots, table and incremental models; views are replayed, not copied), compares them, replays the models on the copy and signs one evidence log for the whole. Unlike the scan, it connects to the warehouse: read-only, with the credentials and the role of the dbt profile. Profile resolution, adapters and what is not copied: commands.

Tested on 2026-10-19 on the test bench: canot drill dbt://lab/dbt <folder> --target snowflake --profiles-dir lab/dbt, Snowflake trial account, profile canot_lab (role CANOT_LAB, warehouse CANOT_WH, key pair from SNOWFLAKE_PRIVATE_KEY_PATH). Measured:

  • Relations: 8 copied (4 sources in RAW, 4 mart tables in DBT_dbt_mart), 24.1 MB, 10.5 s for the whole exercise; 4 staging views not copied, with their reason. The 8 copies are identical to the platform (rows, statistics, fingerprint).
  • Policies: read with the profile's role: RAW.CUSTOMERS carries the row access policy RAP_COUNTRY and the masking policy MASK_EMAIL on EMAIL. The report states that the copy holds what CANOT_LAB can read.
  • Replay (P3): int_order_totals identical, 4 views replayed but not compared, 3 marts different: dbt_fct_orders (150,000 rows on both sides, latest ORDER_DATE 2025-08-22 on the platform, 2025-08-23 on the replay), dbt_customer_ltv (19,924 rows on both sides, FIRST_ORDER and LAST_ORDER shifted), dbt_daily_revenue (14,399 rows on the platform, 14,423 replayed).
  • Why: RAW.ORDERS.ORDER_TS is a TIMESTAMP_TZ in America/Los_Angeles. The Iceberg copy keeps the instant in UTC, not the zone (the drill logs this conversion as lossy); cast(order_ts as date) in stg_orders gives the Los Angeles date on Snowflake and the date in the replay session's zone (here Europe/Paris) on the copy. Evening orders move to the next day. The report gives this cause first, as measured, then the policies on RAW.CUSTOMERS as a possible cause, with the fact that the replayed outputs have the platform's row counts. Entered in the discrepancy catalogue.
  • Evidence: one folder, report.html, report.json, replay-dbt.json, a log of 32 entries accepted by canot verify-evidence; no user name, secret or key path in any file or printed line.

This run differs from the first replay above: with the profile's role CANOT_LAB, the row access policy hides no customer that has orders (the replayed dbt_fct_orders has the platform's 150,000 rows), so the remaining differences come from the time zone. Both are the correct result for their reader; the evidence names the cause each time instead of hiding the difference.

Reading the figures

Every figure carries its nature: measured (read as is), computed (derived from measurements, formula given), estimated (range and assumptions), not read or not estimated (with the reason). Prices are dated, sourced reference points; pass --credit-price with your contract price to replace them. The read cost and the duration of a copy are planning estimates: canot drill replaces them with measurements.

Replayability measures how many query shapes are translated to DuckDB and accepted by it, on an empty skeleton typed from your columns. Accepted does not mean identical result: only canot drill compares results. Queries on tables outside the scope that was read, queries whose text the platform does not provide and metadata queries are counted separately, outside the rate.

What the scan does not see

Reads through the Storage Read API and queries run from other projects (BigQuery), consumers of shares and RESULT_SCAN (Snowflake), jobs that run less often than the window that was read (365 days on Snowflake, 90 to 180 days on BigQuery). The actual window is shown next to each finding.

On-prem exit

Canot writes the copy in Apache Iceberg format. The destination can be a folder on your network, or S3-compatible storage that you host.

DestinationCommandStatus
Local or mounted folder (NAS, disk, network share)--to /path/to/foldertested
Self-hosted S3-compatible storage--to "s3://bucket/canot?endpoint=https://s3.internal:9000&region=us-east-1"tested on a local S3 server (moto)
Custom catalog (SQLite elsewhere)--catalog sqlite:///path/catalog.dbtested
PostgreSQL catalog--catalog env:CANOT_CATALOG_URI with pip install 'canot[postgres]'wired up, not yet tested against a real PostgreSQL
Corporate proxy...?proxy=http://proxy.internal:3128 in the S3 URLwired up, not yet tested
Internal certificate authority...?ca=/path/ca.pem in the S3 URL (passed through AWS_CA_BUNDLE)wired up, not yet tested

Storage keys go through CANOT_S3_ACCESS_KEY and CANOT_S3_SECRET_KEY (or the scw configuration). A catalog password goes in an environment variable, never on the command line: --catalog env:VARIABLE_NAME.

Reading the copy without Canot

Each exercise writes a reread.sql file next to the report. It contains the DuckDB commands that count the rows of each copied table:

duckdb < canot-out/reread.sql

The copy therefore does not depend on the tool: any engine that reads Iceberg (DuckDB, Spark, Trino) can read it. For S3 storage, first create a DuckDB secret with your keys (see the file).

What is not supported

Writing directly to PostgreSQL, ClickHouse or another database engine: the copy stays in Iceberg, which these engines can read. No documented offline installation for now.

Cleaning up

canot teardown --to "s3://bucket/canot?endpoint=https://s3.internal:9000&region=us-east-1"

Targets: sovereign hosting providers and countries

Canot writes to any S3-compatible storage. Built-in shortcuts (--to <provider>:<region> --bucket <name>). Check each endpoint in the provider's console before use: they are still to be confirmed.

ShortcutProviderCountryNote
scaleway:fr-par (also nl-ams, pl-waw)ScalewayFR, NL, PLnot SecNumCloud-qualified (to be checked)
ovh:graOVHcloudFRSNC offering qualified SecNumCloud (to be checked on the ANSSI list)
outscale:eu-west-2OutscaleFRqualification up for renewal at the end of 2026 (to be checked)
ionos:eu-central-1IONOSDE
stackit:eu01STACKIT (Schwarz Group)DE
aws:eu-west-3AWSFRnot a sovereign cloud; AWS European Sovereign Cloud also exists

The same principle in other EU countries

The Data Act applies across the EU: same provider switching rules (Art. 23 to 31), same charges down to zero from 12 Jan 2027. What changes from one country to another: the financial supervisor that enforces DORA, the authority that oversees the Data Act, and national sovereignty requirements.

CountryWhy it is relevantLocal hosting providers
Germanylargest market; BaFin enforces DORA; strong sovereignty cultureSTACKIT, IONOS, Open Telekom Cloud
Netherlandsfinancial center, many scale-ups on SnowflakeLeaseweb
Italymany banks and insurersAruba, Seeweb
Spainbanks; Data Act through the CNMCIONOS, Arsys
Poland, Nordic countriesheavy cloud use, local hosting providersScaleway Warsaw, UpCloud

To confirm before selling: the competent authority designated in each country and the national qualification lists (SecNumCloud equivalents: C5 in Germany, ACN in Italy, ENS in Spain).

A question, or a test to suggest? Write to us