> **Can't find what you're looking for?** Use `search_docs` on the docs MCP server at `https://docs.ampdb.io/api/mcp` to find what you need.

# Admin Client

The admin client manages an Amp from Python, through its [Admin API](/managing-amp/admin-api): the HTTP API of the
controller, the service that stores datasets and schedules the jobs that fill them. With it, you can:

* Turn SQL queries into a [derived dataset](/datasets/derived-datasets), a dataset whose tables Amp computes from other
  datasets, and register it.
* Deploy datasets, and follow or stop the [jobs](/managing-amp/jobs) that extract and transform their data.
* List and explore the datasets an Amp has, and how they depend on each other.
* Store [config maps](/managing-amp/config-maps), and manage [table revisions](/managing-amp/table-revisions).

It's for Amps you run yourself, or hosted ones that give you admin access. To only query data, you don't need it: see
[Querying](/sdks/python/querying).

## Prerequisites

* The Python SDK, installed (see [Installation](/sdks/python#installation)).
* Amp running, with its Admin API reachable. [Solo mode](/running-amp/solo-mode) serves it at `http://localhost:1610`,
  and its query server at `grpc://localhost:1602`. If your Amp needs a token, see
  [Authentication](/sdks/python/authentication).
* For the examples: the `acme/eth_mainnet` dataset from the [Quickstart](/get-started/quickstart), deployed through
  block 24,000,099.

## Connect

Give `Client` the Admin API's URL with `admin_url`, next to the query server's:

```python
from amp import Client

client = Client(query_url='grpc://localhost:1602', admin_url='http://localhost:1610')
```

The client then has three more properties: `client.datasets`, `client.jobs`, and `client.schema`. Without `admin_url`,
using them raises a `ValueError`.

Config maps and table revisions are only on `AdminClient`, which talks to the Admin API alone. It has the same three
properties, plus `configmaps` and `revisions`:

```python
from amp.admin import AdminClient

with AdminClient('http://localhost:1610') as admin:
    print(admin.datasets.list_all())
```

`with` closes its connections when the block ends. Outside one, call `admin.close()` when you're done.

## Turn a query into a dataset

A derived dataset's tables are SQL queries over its dependencies, other datasets it reads from. In a query, you refer to
a dependency by an alias, a short name you choose, like `eth` in `eth.blocks`. `with_dependency()` maps the alias to the
dataset, and `register_as()` registers the query as a one-table dataset:

```python [register.py]
from amp import Client

client = Client(query_url='grpc://localhost:1602', admin_url='http://localhost:1610')

dataset = (
    client.sql('SELECT block_num, timestamp, gas_used, gas_limit FROM eth.blocks')
    .with_dependency('eth', 'acme/eth_mainnet@1.0.0')
    .register_as('acme', 'gas_usage', '1.0.0', 'blocks')
)
print(f'Registered {dataset.reference}')
dataset.inspect()
```

:::terminal
```bash
python register.py
```

```
Registered acme/gas_usage@1.0.0

Dataset: acme/gas_usage@1.0.0

blocks (6 columns)
  _block_num           UInt64               NOT NULL
  _ts                  Timestamp(Nanosecond) NOT NULL
  block_num            UInt64               NOT NULL
  timestamp            Timestamp(Nanosecond) NOT NULL
  gas_used             UInt64               NOT NULL
  gas_limit            UInt64               NOT NULL
```
:::

`register_as('acme', 'gas_usage', '1.0.0', 'blocks')` names the dataset `acme/gas_usage`, its version `1.0.0`, and its
one table `blocks`. Before registering, the SDK asks Amp for the query's output columns, and writes them into the
dataset's [manifest](/datasets/manifests), with the [reserved columns](/datasets/manifests#reserved-columns)
`_block_num` and `_ts` that Amp adds. It returns a `Dataset`, a handle on that version with the manifest it built.

Amp updates a derived dataset's tables as new blocks arrive, so their queries must be incremental: each new block's rows
must depend only on that block. Filters, projections, and joins are; aggregations, like `GROUP BY` and `count(*)`,
aren't, and registering them fails (see [Troubleshooting](/sdks/python/admin#troubleshooting)). [Derived
Datasets](/datasets/derived-datasets) explains what queries can do.

### Generate the manifest only

`to_manifest()` builds the same manifest without registering it, so you can review it, save it, or register it later
with [`ampctl`](/clis/ampctl/dataset):

```python [manifest.py]
import json

from amp import Client

client = Client(query_url='grpc://localhost:1602', admin_url='http://localhost:1610')

manifest = (
    client.sql('SELECT block_num, gas_used FROM eth.blocks')
    .with_dependency('eth', 'acme/eth_mainnet@1.0.0')
    .to_manifest('block_gas')
)
print(json.dumps(manifest, indent=2))
```

:::terminal
```bash
python manifest.py
```

```json
{
  "kind": "manifest",
  "dependencies": {
    "eth": "acme/eth_mainnet@1.0.0"
  },
  "tables": {
    "block_gas": {
      "input": {
        "sql": "SELECT block_num, gas_used FROM eth.blocks"
      },
      "schema": {
        "arrow": {
          "fields": [
            {
              "name": "_block_num",
              "type": "UInt64",
              "nullable": false
            },
            {
              "name": "_ts",
              "type": {
                "Timestamp": ["Nanosecond", "+00:00"]
              },
              "nullable": false
            },
            {
              "name": "block_num",
              "type": "UInt64",
              "nullable": false
            },
            {
              "name": "gas_used",
              "type": "UInt64",
              "nullable": false
            }
          ]
        }
      },
      "network": "mainnet"
    }
  },
  "functions": {}
}
```
:::

Each table gets a `network`, the chain its rows come from. It's `mainnet` unless you pass another, like
`to_manifest('block_gas', network='base')`.

To register a manifest you have, built here or written by hand, use `client.datasets.register()`:

```python
client.datasets.register('acme', 'block_gas', '1.0.0', manifest)
```

Registering a version that already exists replaces its manifest, unless the manifest is the same, which changes nothing.

### Register several tables

`client.datasets.register_as()` registers a dataset with several tables, one query each. Before registering, it checks
each query by asking the query server to plan it, so errors show up now rather than when the dataset runs. The query
server doesn't know aliases from `with_dependency()`, so here you name dependencies with [dataset
aliases](/sdks/python/querying#dataset-aliases) instead: `client.aliases.register()` saves an alias on your computer,
the SDK replaces it with the dataset's name in queries it sends, and it adds the dataset to the manifest's dependencies.

```python [register_tables.py]
from amp import Client

client = Client(query_url='grpc://localhost:1602', admin_url='http://localhost:1610')
client.aliases.register('eth', 'acme/eth_mainnet@1.0.0', force=True)

dataset = client.datasets.register_as(
    'acme',
    'token_activity',
    '1.0.0',
    tables={
        'transfers': client.sql(
            'SELECT block_num, tx_hash, address AS token FROM eth.logs '
            "WHERE topic0 = evm_topic('Transfer(address indexed from, address indexed to, uint256 value)')"
        ),
        'contract_creations': client.sql(
            'SELECT block_num, tx_hash, "from" AS creator FROM eth.transactions WHERE "to" IS NULL'
        ),
    },
)
print(f'Registered {dataset.reference}')
print(f'Tables: {", ".join(dataset.manifest["tables"])}')
print(f'Dependencies: {dataset.manifest["dependencies"]}')
```

:::terminal
```bash
python register_tables.py
```

```
Registered alias 'eth' → acme/eth_mainnet@1.0.0
Registered acme/token_activity@1.0.0
Tables: transfers, contract_creations
Dependencies: {'eth': 'acme/eth_mainnet@1.0.0'}
```
:::

`force=True` replaces an `eth` alias you registered before. Aliases stay registered for your later scripts; see
[Querying](/sdks/python/querying) for managing them.

### Add tables in a new version

`add_tables()` publishes a new version with the tables of an existing one, plus new ones. It never replaces a table:
adding one whose name exists raises a `ValueError`.

```python [add_tables.py]
from amp import Client

client = Client(query_url='grpc://localhost:1602', admin_url='http://localhost:1610')

dataset = client.datasets.get('acme', 'token_activity', '1.0.0')
new_version = dataset.add_tables(
    {
        'approvals': client.sql(
            'SELECT block_num, tx_hash, address AS token FROM eth.logs '
            "WHERE topic0 = evm_topic('Approval(address indexed owner, address indexed spender, uint256 value)')"
        ),
    },
    new_version='1.1.0',
)
print(f'Registered {new_version.reference}')
print(f'Tables: {", ".join(new_version.manifest["tables"])}')
```

:::terminal
```bash
python add_tables.py
```

```
Registered acme/token_activity@1.1.0
Tables: contract_creations, transfers, approvals
```
:::

`client.datasets.get()` returns a `Dataset` handle for a version that exists, with its manifest. Version `1.0.0` stays
as it was.

## Deploy a dataset

Registering doesn't compute anything. Deploying does: Amp schedules a job that fills the dataset's tables, from its
dependencies' data. `deploy()` returns as soon as the job is scheduled, so this script then checks the job's status
until it's done:

```python [deploy.py]
import time

from amp import Client

client = Client(query_url='grpc://localhost:1602', admin_url='http://localhost:1610')

dataset = client.datasets.get('acme', 'gas_usage', '1.0.0')
dataset.deploy(end_block='24000099')

while True:
    status = dataset.status()
    if status and status.job_status in ('COMPLETED', 'FATAL', 'STOPPED'):
        break
    time.sleep(2)

print(f'Job {status.job_id} is {status.job_status}')
for table, progress in status.tables.items():
    for synced in progress.synced_ranges:
        print(f'{table}: blocks {synced["numbers"]["start"]} to {synced["numbers"]["end"]}')
```

:::terminal
```bash
python deploy.py
```

```
Job 23 is COMPLETED
blocks: blocks 24000000 to 24000099
```
:::

Your job's ID may differ. `end_block='24000099'` stops the job at the last block the Quickstart extracted. Without it,
the job keeps running, and processes new blocks as its dependencies get them. `dataset.status()` returns the status of
the version's job, or `None` before you deploy it. `COMPLETED`, `FATAL`, and `STOPPED` are the
[statuses](/managing-amp/jobs/lifecycle) a job ends in.

The query server serves the new dataset right away:

```python [query_derived.py]
from amp import Client

client = Client('grpc://localhost:1602')

df = client.sql(
    'SELECT block_num, gas_used, gas_limit FROM "acme/gas_usage".blocks ORDER BY block_num LIMIT 3'
).to_polars()
print(df)
```

:::terminal
```bash
python query_derived.py
```

```
shape: (3, 3)
┌───────────┬──────────┬───────────┐
│ block_num ┆ gas_used ┆ gas_limit │
│ ---       ┆ ---      ┆ ---       │
│ u64       ┆ u64      ┆ u64       │
╞═══════════╪══════════╪═══════════╡
│ 24000000  ┆ 57860235 ┆ 59999886  │
│ 24000001  ┆ 27660983 ┆ 60000000  │
│ 24000002  ┆ 17602635 ┆ 60000000  │
└───────────┴──────────┴───────────┘
```
:::

`deploy()` takes these options, on a `Dataset` handle and on `client.datasets.deploy()`:

| Option           | Type    | Default                  | Description                                                                                                                          |
| ---------------- | ------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `end_block`      | string  | `None` (never stops)     | The block the job stops at, as a string, like `'24000099'`.                                                                          |
| `concurrency`    | integer | `1`                      | How many extractions the job runs at once, for providers that support it, like `concurrency=4`.                                      |
| `retry_strategy` | dict    | Amp's default            | How Amp retries the job when it fails, like `{'strategy': 'bounded', 'max_attempts': 3}`. See [Retries](/managing-amp/jobs/retries). |
| `verify`         | string  | `None` (no verification) | Check extracted data against the chain's hashes while extracting, with `'ethereum'` or `'solana'`. Raw datasets only.                |
| `worker_id`      | string  | Any worker               | The [worker](/managing-amp/workers) to run the job on: an ID, or a pattern like `'worker-eth-*'`.                                    |
| `wait`           | boolean | `False`                  | `Dataset.deploy()` only: wait for the job to end. See the warning below.                                                             |

While a version's job is still running, deploying it again returns that job instead of scheduling another.

:::warning
`wait=True`, and `client.jobs.wait_for_completion()` which it calls, wait for statuses named `Completed`, `Failed`, and
`Stopped`, but Amp reports them in capitals, like `COMPLETED`. They never see the job end: without a `timeout`, they
wait forever. Check the status yourself, as `deploy.py` does.
:::

## Follow and stop jobs

A deployed version runs two jobs: the one that fills its tables, and a garbage collection job that Amp runs every hour
to delete files the dataset no longer uses. `client.datasets.list_jobs()` returns both, and `dataset.jobs()` does the
same for a handle.

This script deploys a version without an end block, so its job keeps running, then stops the job and lists what happened
to it:

```python [follow.py]
import time

from amp import Client

client = Client(query_url='grpc://localhost:1602', admin_url='http://localhost:1610')

dataset = client.datasets.get('acme', 'token_activity', '1.1.0')
dataset.deploy()
time.sleep(10)

job_id = dataset.status().job_id
print(f'Job {job_id} is {client.jobs.get(job_id).status}')

client.jobs.stop(job_id)
while (status := client.jobs.get(job_id).status) != 'STOPPED':
    time.sleep(1)

for event in client.jobs.get_events(job_id).events:
    print(f'{event.created_at}  {event.event_type}')
```

:::terminal
```bash
python follow.py
```

```
Job 26 is RUNNING
2026-10-30T21:11:59.719387+00:00  SCHEDULED
2026-10-30T21:11:59.727919+00:00  RUNNING
2026-10-30T21:12:09.755697+00:00  STOP_REQUESTED
2026-10-30T21:12:09.758560+00:00  STOPPING
2026-10-30T21:12:09.760559+00:00  STOPPED
```
:::

`client.jobs.stop()` asks the job to stop, and returns: the job moves through `STOP_REQUESTED` and `STOPPING` to
`STOPPED`. `client.jobs.get_events()` lists the changes in a job's status, and `client.jobs.get_event()` returns one of
them, with its details. [Job Lifecycle](/managing-amp/jobs/lifecycle) explains each status.

`client.datasets.stats()` summarizes how far each table of a version has synced:

```python [stats.py]
from amp import Client

client = Client(query_url='grpc://localhost:1602', admin_url='http://localhost:1610')

stats = client.datasets.stats('acme', 'token_activity', '1.1.0')
print(f'Job {stats.job_id} is {stats.job_status}')
for table, info in stats.tables.items():
    print(f'{table}: synced through {info.data_synced_through}, {info.data_lag_seconds:,} seconds behind')
```

:::terminal
```bash
python stats.py
```

```
Job 26 is STOPPED
transfers: synced through 2025-12-13T00:25:59+00:00, 27,809,172 seconds behind
approvals: synced through 2025-12-13T00:25:59+00:00, 27,809,172 seconds behind
contract_creations: synced through 2025-12-13T00:25:59+00:00, 27,809,172 seconds behind
```
:::

`data_synced_through` is the time of the last block synced, and `data_lag_seconds` how long ago that was. For a job's
full status, with each table's files, size, and synced block ranges, use `client.jobs.get_status(job_id)`.

To clean up jobs that ended, `client.jobs.delete(job_id)` deletes one, and `client.jobs.delete_by_status('terminal')`
deletes every job that ended. `'completed'`, `'stopped'`, and `'error'` delete only the jobs that ended that way.
`client.jobs.list()` lists the jobs that haven't ended yet.

## Explore an Amp

These methods show what an Amp has. Each returns a model whose fields the [reference](/sdks/python/reference/admin)
lists:

```python [explore.py]
from amp import Client

client = Client(query_url='grpc://localhost:1602', admin_url='http://localhost:1610')

for dataset in client.datasets.list_all().datasets:
    print(f'{dataset.namespace}/{dataset.name}: {", ".join(dataset.versions)}')

versions = client.datasets.get_versions('acme', 'token_activity')
print(f'latest is {versions.special_tags.latest}')

info = client.datasets.get_version('acme', 'token_activity', 'latest')
print(f'{info.kind}, tables {", ".join(info.tables)}, manifest {info.manifest_hash[:12]}…')
```

:::terminal
```bash
python explore.py
```

```
acme/eth_mainnet: 1.0.0
acme/gas_usage: 1.0.0
acme/token_activity: 1.1.0, 1.0.0
latest is 1.1.0
manifest, tables approvals, contract_creations, transfers, manifest 83e9722525bc…
```
:::

Methods that take a version, like `get_version()`, also take `latest`, `dev`, or a manifest hash (see
[Dataset references](/managing-amp/datasets#dataset-references)). `client.datasets.get_manifest()` returns a version's
manifest, and [Inspecting Datasets](/sdks/python/inspecting-datasets) shows its tables and columns.

`client.datasets.lineage()` returns how datasets depend on each other (see [Lineage](/managing-amp/lineage)).
`direction='upstream'`, the default, follows a dataset's dependencies; `'downstream'` finds the datasets that depend on
it:

```python [lineage.py]
from amp import Client

client = Client(query_url='grpc://localhost:1602', admin_url='http://localhost:1610')

lineage = client.datasets.lineage('acme', 'eth_mainnet', '1.0.0', direction='downstream')
for edge in lineage.edges:
    print(f'{edge.target} reads {edge.source} as {edge.alias}')
```

:::terminal
```bash
python lineage.py
```

```
acme/gas_usage@1.0.0 reads acme/eth_mainnet@1.0.0 as eth
acme/token_activity@1.1.0 reads acme/eth_mainnet@1.0.0 as eth
acme/token_activity@1.0.0 reads acme/eth_mainnet@1.0.0 as eth
```
:::

## Remove datasets

`client.datasets.delete_version()` removes one version, and `client.datasets.delete()` removes a dataset, with all its
versions:

```python
client.datasets.delete_version('acme', 'token_activity', '1.0.0')
client.datasets.delete('acme', 'gas_usage')
```

You can't remove the version `latest` points to: register a newer one first. Removing a dataset that doesn't exist
does nothing. [Managing Datasets](/managing-amp/datasets) explains what happens to the data.

## Config maps

A [config map](/managing-amp/config-maps) is a JSON document that Amp stores under a namespace and a name, like an RPC
endpoint's settings. In this release, no part of Amp loads config maps yet. The SDK sends and returns documents as
Python dicts, without checking them against their `kind`. Each write creates a new generation of the config map, with
its own ID.

```python [configmaps.py]
from amp.admin import AdminClient

with AdminClient('http://localhost:1610') as admin:
    configmap = admin.configmaps.create(
        'acme',
        'mainnet_limits',
        'evm-rpc',
        {'url': '${{ env.RPC_URL }}', 'rate_limit_per_minute': 600},
    )
    print(f'Created {configmap.reference}: {configmap.data}')

    configmap = configmap.update({**configmap.data, 'rate_limit_per_minute': 300})
    print(f'Updated {configmap.reference}: {configmap.data}')

    for summary in admin.configmaps.list('acme'):
        print(f'{summary.name} ({summary.kind}), updated {summary.updated_at}')

    configmap.delete()
```

:::terminal
```bash
python configmaps.py
```

```
Created acme/mainnet_limits: {'url': '${{ env.RPC_URL }}', 'rate_limit_per_minute': 600}
Updated acme/mainnet_limits: {'url': '${{ env.RPC_URL }}', 'rate_limit_per_minute': 300}
mainnet_limits (evm-rpc), updated 2026-10-30T21:12:20.067901+00:00
```
:::

* `create()` creates the config map, or replaces the one with that name.
* `update()` on a `ConfigMap` replaces it only if nobody wrote to it since you read it: otherwise it raises
  `ConfigMapPreconditionFailedError`, and you read it again with `admin.configmaps.get()` to apply your change on top of
  theirs. `admin.configmaps.update()` does the read for you, and never creates a config map.
* `list()` returns a namespace's config maps without their documents.
* `delete()` succeeds even when the config map doesn't exist, and doesn't check whether anything still reads it.

`get()` returns a document exactly as it was stored, so write secrets as references to environment variables, like
`${{ env.RPC_URL }}`, never as values (see [Secrets in Config Maps](/managing-amp/config-maps/secrets)).

## Table revisions

A table revision is one of the storage locations a table's files can live in; the active one is where queries read.
`admin.revisions` lists, creates, activates, prunes, truncates, restores, and deletes them, for operations like moving
a table's data. [Table Revisions](/managing-amp/table-revisions) explains when to use them, and the
[reference](/sdks/python/reference/admin#revisionsclient) lists every method.

```python
from amp.admin import AdminClient

with AdminClient('http://localhost:1610') as admin:
    for revision in admin.revisions.list(active=True):
        print(revision.id, revision.metadata.table_name, revision.path)
```

## Errors

When the Admin API rejects a request, the SDK raises an `AdminAPIError`, or one of its subclasses for the common
errors, like `DatasetNotFoundError`. Each has the API's `error_code`, its `message`, and the HTTP `status_code`:

```python
from amp.admin import AdminAPIError, DatasetNotFoundError

try:
    client.datasets.get_version('acme', 'nope', '1.0.0')
except DatasetNotFoundError as error:
    print(error.status_code, error.error_code)  # 404 DATASET_NOT_FOUND
except AdminAPIError as error:
    print(error)
```

The [reference](/sdks/python/reference/admin#errors) lists the subclasses.

## Troubleshooting

### `ValueError: Admin API not configured. Provide admin_url parameter to Client() …`

The `Client` was created without `admin_url`. Pass the Admin API's URL, like `admin_url='http://localhost:1610'`.

### `httpx.ConnectError: … Connection refused`

Nothing answers at `admin_url`. Check that Amp is running, and that the URL's port is its Admin API's: `1610` in solo
mode. Port `1602` is the query server.

### `AdminAPIError: [DEPENDENCY_NOT_FOUND] Dependency 'eth' (acme/missing@1.0.0) not found`

A dependency isn't registered in this Amp, or not at that version. Check the reference you passed to
`with_dependency()`, and the dataset's versions with `client.datasets.get_versions()`.

### `AdminAPIError: [INVALID_PLAN] Failed to plan SQL for table …`

Amp couldn't plan the table's query. The message says why, like a column that doesn't exist:
`Schema error: No field named nope. Did you mean 'eth.blocks.nonce'?`. Fix the query, and run it with
`client.sql()` to check it.

### `AdminAPIError: [NON_INCREMENTAL_QUERY] Table 'log_counts' contains non-incremental SQL …`

The query aggregates, like `GROUP BY` or `count(*)`, which derived datasets can't. Register the rows instead, and
aggregate when you query the dataset.

### `ValueError: SQL validation failed for … table(s) …` with `table 'amp.eth.logs' not found`

`client.datasets.register_as()` or `add_tables()` checked a query that uses an alias from `with_dependency()`, which the
query server doesn't know. Register the alias with `client.aliases.register()` instead (see
[Register several tables](/sdks/python/admin#register-several-tables)).

### `AdminAPIError: [CANNOT_DELETE_LATEST_VERSION] Cannot delete version tagged as 'latest' …`

`latest` points to the version you're removing. Register a newer version first, or remove the whole dataset with
`client.datasets.delete()`.

### `TimeoutError: Job … did not complete within … seconds. Current status: COMPLETED`

`client.jobs.wait_for_completion()` doesn't recognize the statuses Amp reports, so it waits until its `timeout` even
when the job ended. Check `client.jobs.get(job_id).status` yourself (see [Deploy a dataset](/sdks/python/admin#deploy-a-dataset)).

### The job is `FATAL` right after you deploy, with `start block (24000100) is greater than end block (24000099)`

That manifest's tables already reached the end block, from an earlier deployment of this version, or of another
dataset with the same manifest. There's nothing left to compute, and the data is still there to query.
