> ## Documentation Index
> Fetch the complete documentation index at: https://docs.startree.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Unity Catalog: Onboarding via API

<Warning>
  This feature requires StarTree release 0.16.0 or later, and must be enabled on demand — contact StarTree support to activate it.
</Warning>

This page shows how to onboard a **Unity Catalog** External Table with the StarTree controller REST APIs. There is no Data Portal wizard for Unity Catalog yet — this API flow is the only way to onboard one today.

Unity Catalog tables are accessed over the **Iceberg REST protocol** (`catalogType=iceberg-rest`, `serviceType=unity`), the same mechanism used for [AWS Glue](../glue/onboarding-api) and [Amazon S3 Tables](../s3tables/onboarding-api) — only the endpoint and auth differ. This works for both OSS Unity Catalog and Databricks-managed Unity Catalog, and for **Delta Lake tables with [UniForm](https://docs.delta.io/latest/delta-uniform.html) enabled** (UniForm publishes Iceberg-compatible metadata alongside the Delta log, so a Delta+UniForm table registered in Unity Catalog is queryable the same way as a native Iceberg table). Data files must be **Parquet**.

<Warning>
  **Known limitation: Delta tables with column mapping.** Delta's `columnMapping.mode` set to `name` or `id` — mandatory for Iceberg Compatibility V2, which covers most Databricks-managed UniForm tables — syncs successfully but currently fails segment load with `org.apache.parquet.io.InvalidRecordException: <logical_name> not found in message spark_schema`. A fix is planned but not yet available. Before onboarding a Databricks UniForm table, check its `delta.columnMapping.mode` table property; if it's `name` or `id`, contact StarTree support to confirm current status before proceeding.
</Warning>

> Using **AWS Glue** instead? See [AWS Glue: Onboarding via API](../glue/onboarding-api).
>
> Using **Amazon S3 Tables** instead? See [Amazon S3 Tables: Onboarding via API](../s3tables/onboarding-api).

## How it works

Onboarding is four calls. Each one feeds the next:

```text theme={null}
1. Validate & browse   POST /connections/browse   →  validate; list namespaces/tables
2. Preview             POST /tables/preview       →  infer schema + enriched table config
3. Create schema       POST /schemas              →  register the schema
4. Create table        POST /tables               →  register the External Table
```

The preview call (step 2) is the core step: it samples the source, infers a Pinot schema, and returns a ready-to-use table config. Steps 3 and 4 just persist its output.

Once the table exists, the controller's watcher runs the first sync and re-syncs on schedule — no manual trigger. Track progress with the [observability endpoints](#monitor-onboarding).

<Note>
  All paths are relative to your controller base URL. The examples assume `export CONTROLLER=https://<your-controller>`. On StarTree Cloud, the controller is reached through the data-plane proxy, so use `export CONTROLLER=https://<data-plane-host>/api/pinot`.

  If your controller requires authentication, add an `Authorization` header to every request — e.g. `-H "Authorization: Bearer <token>"`. The examples below omit it for brevity.
</Note>

## Prerequisites

* StarTree 0.16.0 or later with the External Table Beta feature enabled, and **tiered storage configured** on the cluster. Contact StarTree support if unsure.
* Network access to the controller REST endpoint, plus an `Authorization` token if your cluster requires auth.
* A running Unity Catalog:
  * **Databricks-managed** — a workspace with Unity Catalog enabled, plus a **personal access token (PAT)** generated from the workspace (**User Settings → Developer → Access tokens**).
  * **OSS Unity Catalog** — a reachable server. The OSS server is unauthenticated by default; no token is needed.
* The Unity Catalog **catalog name** (used as `warehouse` below) and the namespace/table to onboard.
* Read access to the underlying object storage. Unity Catalog **vends short-lived storage credentials automatically** for tables it manages, so static keys are usually unnecessary — see [Authentication](#authentication).

## Authentication

An External Table authenticates in two places, configured independently:

* **Catalog (REST)** — keys under `catalog.iceberg-rest.auth.rest.*`.
  * Databricks (personal access token): set `catalog.iceberg-rest.auth.rest.token` to the PAT. `authType` doesn't need to be set explicitly — it's auto-detected as token auth when `.token` is present.
  * Databricks (service principal / machine-to-machine, recommended for automated pipelines): use **OAuth2 client-credentials** instead of a PAT — see below.
  * OSS Unity Catalog: omit `auth.rest.*` entirely.
* **Storage (S3 data files)** — keys under `catalog.iceberg-rest.auth.storage.*`. Unity Catalog vends short-lived S3 credentials per table load, and those vended credentials take priority over anything set here — so `accessKeyId`/`secretAccessKey` can usually be left blank. Set them only if you need to override the vended credentials (e.g. a fixed IAM role for the tier's deep-store access, separate from the source read path).

### OAuth2 client-credentials (new in 0.16.0)

For Databricks service-principal / machine-to-machine access — the preferred method over a long-lived PAT for automated pipelines — set `authType=oauth2` (or omit it; it's auto-detected when all three required keys below are present) plus:

| Key                                                | Required | Description                                                                    |
| -------------------------------------------------- | -------- | ------------------------------------------------------------------------------ |
| `catalog.iceberg-rest.auth.rest.authType`          | No       | `oauth2` (auto-detected if the three keys below are all set).                  |
| `catalog.iceberg-rest.auth.rest.oauthTokenUri`     | Yes      | OAuth2 token endpoint.                                                         |
| `catalog.iceberg-rest.auth.rest.oauthClientId`     | Yes      | Service-principal client ID.                                                   |
| `catalog.iceberg-rest.auth.rest.oauthClientSecret` | Yes      | Service-principal client secret.                                               |
| `catalog.iceberg-rest.auth.rest.oauthScopes`       | No       | Space-separated OAuth2 scopes, if your Unity Catalog deployment requires them. |

The client-credentials grant (RFC 6749) is exchanged for a short-lived token that's cached and refreshed automatically ahead of expiry — you don't need to rotate or refresh anything yourself. Note the [SQL DDL](../sql-ddl) shortcut for this same setup uses `auth_type = 'oauth'`, which normalizes to this OAuth2 configuration.

## `restUri` formats

| Deployment                       | Example `restUri`                                             |
| -------------------------------- | ------------------------------------------------------------- |
| Databricks-managed Unity Catalog | `https://<workspace-host>/api/2.1/unity-catalog/iceberg-rest` |
| OSS Unity Catalog                | `http://<host>:8080/api/2.1/unity-catalog/iceberg`            |

Both suffixes are exactly what you provide — StarTree doesn't rewrite the host or path, it only appends the standard Iceberg REST `/v1/...` routes.

***

## Step 1: Validate and browse the connection

**`POST /connections/browse`**

There is no separate "validate" endpoint. Browsing the catalog *is* the validation step: a `200` with an `items` list (even an empty one) confirms your credentials and connectivity.

* Set `path` to `""` to browse the **root** — this both validates the connection and lists namespaces.
* Set `path` to a namespace name to list the **tables** inside it.

### Request

| Field               | Type   | Required | Description                                                                          |
| ------------------- | ------ | -------- | ------------------------------------------------------------------------------------ |
| `connection.type`   | string | Yes      | Use `CATALOG` for all External Table sources.                                        |
| `connection.params` | object | Yes      | Catalog-specific connection settings (see example below).                            |
| `path`              | string | No       | Where to browse. `""` or omitted = root. A namespace name = that namespace's tables. |

<Tabs>
  <Tab title="Databricks-managed">
    ```bash theme={null}
    curl -X POST "$CONTROLLER/connections/browse" \
      -H "Content-Type: application/json" \
      -d '{
        "connection": {
          "type": "CATALOG",
          "params": {
            "catalogType": "iceberg-rest",
            "catalog.iceberg-rest.serviceType": "unity",
            "catalog.iceberg-rest.restUri": "https://<workspace-host>/api/2.1/unity-catalog/iceberg-rest",
            "catalog.iceberg-rest.warehouse": "<unity-catalog-name>",
            "catalog.iceberg-rest.auth.rest.token": "<databricks-pat>"
          }
        },
        "path": ""
      }'
    ```
  </Tab>

  <Tab title="OSS Unity Catalog">
    ```bash theme={null}
    curl -X POST "$CONTROLLER/connections/browse" \
      -H "Content-Type: application/json" \
      -d '{
        "connection": {
          "type": "CATALOG",
          "params": {
            "catalogType": "iceberg-rest",
            "catalog.iceberg-rest.serviceType": "unity",
            "catalog.iceberg-rest.restUri": "http://<host>:8080/api/2.1/unity-catalog/iceberg",
            "catalog.iceberg-rest.warehouse": "<unity-catalog-name>"
          }
        },
        "path": ""
      }'
    ```
  </Tab>
</Tabs>

### Response

```json theme={null}
{
  "items": [
    { "name": "default", "type": "NAMESPACE" },
    { "name": "countries", "type": "TABLE" }
  ]
}
```

| `items[].type` | Meaning                                                     |
| -------------- | ----------------------------------------------------------- |
| `NAMESPACE`    | A namespace you can drill into — pass its `name` as `path`. |
| `TABLE`        | A selectable table.                                         |

***

## Step 2: Preview the schema

**`POST /tables/preview`**

Samples the source, infers a Pinot schema, and returns an enriched table config (S3 tier, raw field configs, time column) plus sample rows. Review it, tweak if needed, then carry the schema and config forward to steps 3 and 4.

<Note>
  The request and response share the same JSON shape. You send a `tableConfig` describing the source; the response fills in `schema`, the enriched `tableConfigs.offline`, sampled `rows`, and a `summary`.
</Note>

### Request

**Top-level fields:**

| Field         | Type   | Required | Description                                                                             |
| ------------- | ------ | -------- | --------------------------------------------------------------------------------------- |
| `tableConfig` | object | Yes      | A single OFFLINE table config whose `ExternalTableSyncTask` block describes the source. |
| `config`      | object | No       | Sampling and inference controls (see below). Defaults are applied if omitted.           |
| `schema`      | object | No       | An explicit schema to use instead of inferring one.                                     |

**`config.inference` — how the schema is derived:**

| Field                    | Type    | Default | Description                                                                                   |
| ------------------------ | ------- | ------- | --------------------------------------------------------------------------------------------- |
| `embeddedSchemaEnabled`  | boolean | `false` | Read the schema embedded in the Iceberg metadata. **Set this to `true` for External Tables.** |
| `schemaInferenceEnabled` | boolean | `false` | Infer the schema from sampled rows. Fallback when no embedded schema exists.                  |

Resolution order: explicit `schema` → embedded schema → inferred schema.

**`config.sampling` — how rows are sampled:**

| Field | Type    | Default | Description                |
| ----- | ------- | ------- | -------------------------- |
| `min` | integer | `1`     | Minimum records to sample. |
| `max` | integer | `100`   | Maximum records to sample. |

<Tip>
  To list source files without sampling any data, set `config.previewFiles.previewFilesOnly = true`. The response returns matching file URIs in `sourceFiles` and skips schema inference. (OFFLINE only.)
</Tip>

```bash theme={null}
curl -X POST "$CONTROLLER/tables/preview" \
  -H "Content-Type: application/json" \
  -d '{
    "tableConfig": {
      "tableName": "countries_OFFLINE",
      "tableType": "OFFLINE",
      "task": {
        "taskTypeConfigsMap": {
          "ExternalTableSyncTask": {
            "catalogType": "iceberg-rest",
            "executor": "controller",
            "inputFormat": "parquet",
            "catalog.iceberg-rest.serviceType": "unity",
            "catalog.iceberg-rest.restUri": "https://<workspace-host>/api/2.1/unity-catalog/iceberg-rest",
            "catalog.iceberg-rest.warehouse": "<unity-catalog-name>",
            "catalog.iceberg-rest.table.namespace": "default",
            "catalog.iceberg-rest.table.tableName": "countries",
            "catalog.iceberg-rest.auth.rest.token": "<databricks-pat>",
            "catalog.iceberg-rest.auth.storage.region": "us-west-1"
          }
        }
      }
    },
    "config": { "inference": { "embeddedSchemaEnabled": true } }
  }'
```

<Note>
  **Setting the namespace and table.** Take them from the [browse](#step-1-validate-and-browse-the-connection) response — set `catalog.iceberg-rest.table.namespace` / `.tableName` to the `NAMESPACE` and `TABLE` you selected.

  **`auth.storage.region`** is required even when relying on Unity's vended storage credentials (see [Authentication](#authentication)) — it tells the S3 client which region to sign requests for.

  **Use `executor: controller`** — it's required for the controller-watcher flow and the [observability endpoints](../observability). The input `tableConfig` is intentionally minimal; `/tables/preview` returns the complete `tableConfigs.offline` you persist in Step 4.
</Note>

### Response

| Field                  | Type   | Description                                                                                                 |
| ---------------------- | ------ | ----------------------------------------------------------------------------------------------------------- |
| `schema`               | object | The resolved Pinot schema. **Use this in step 3.**                                                          |
| `tableConfigs.offline` | object | The enriched OFFLINE table config, ready to persist. **Use this in step 4.** Secrets are masked as `*****`. |
| `rows`                 | array  | Sample records after schema + ingestion transforms.                                                         |
| `sourceRows`           | array  | Raw records before transformation.                                                                          |
| `summary`              | object | Run summary: `nSourceRows`, `nRows`, `nColumns`, and `summary.batch.nMatchingFiles` (files found).          |

Adjust the schema (time column, column names, null handling) before moving on.

***

## Step 3: Create the schema

**`POST /schemas`**

Send the `schema` object from the preview response. Rename its `schemaName` to match your table.

```bash theme={null}
curl -X POST "$CONTROLLER/schemas" \
  -H "Content-Type: application/json" \
  -d @schema.json
```

```json theme={null}
{ "status": "countries successfully added" }
```

***

## Step 4: Create the table

**`POST /tables`**

Send the enriched `tableConfigs.offline` object from the preview response. Its `ExternalTableSyncTask` block is what marks the table as external.

```bash theme={null}
curl -X POST "$CONTROLLER/tables" \
  -H "Content-Type: application/json" \
  -d @tableConfig.json
```

```json theme={null}
{ "status": "Table countries_OFFLINE successfully added" }
```

The controller's External Table watcher then discovers the table, runs the first sync, and re-syncs at the `schedule` (cron) interval. There is no separate start call.

<Tip>
  The first sync runs on the watcher's next tick. To kick it off immediately instead of waiting, you can manually trigger a run:

  ```bash theme={null}
  curl -X POST "$CONTROLLER/tasks/schedule?taskType=ExternalTableSyncTask&tableName=countries_OFFLINE"
  ```
</Tip>

***

## Quickstart: onboard a table end-to-end

The whole flow as one script (Databricks-managed Unity Catalog). Fill in the variables at the top, run the script, and the first sync starts automatically. `schema.json` and `tableConfig.json` — referenced in steps 3 and 4 above — are extracted here from the preview response with `jq`.

```bash theme={null}
#!/usr/bin/env bash
set -euo pipefail

CONTROLLER="https://<your-controller>"
AUTH="Authorization: Bearer <token>"
REST_URI="https://<workspace-host>/api/2.1/unity-catalog/iceberg-rest"
WAREHOUSE="<unity-catalog-name>"
NAMESPACE="default"
TABLE="countries"
PAT="<databricks-pat>"
STORAGE_REGION="us-west-1"

# 1. Validate & browse root — lists Unity Catalog namespaces.
curl -sf -X POST "$CONTROLLER/connections/browse" \
  -H "$AUTH" -H 'Content-Type: application/json' \
  --data-raw "{
    \"connection\": {
      \"type\": \"CATALOG\",
      \"params\": {
        \"catalogType\": \"iceberg-rest\",
        \"catalog.iceberg-rest.serviceType\": \"unity\",
        \"catalog.iceberg-rest.restUri\": \"$REST_URI\",
        \"catalog.iceberg-rest.warehouse\": \"$WAREHOUSE\",
        \"catalog.iceberg-rest.auth.rest.token\": \"$PAT\"
      }
    },
    \"path\": \"\"
  }" | jq .

# 2. Preview — infer schema + enriched table config.
curl -sf -X POST "$CONTROLLER/tables/preview" \
  -H "$AUTH" -H 'Content-Type: application/json' \
  --data-raw "{
    \"tableConfig\": {
      \"tableName\": \"${TABLE}_OFFLINE\",
      \"tableType\": \"OFFLINE\",
      \"task\": {
        \"taskTypeConfigsMap\": {
          \"ExternalTableSyncTask\": {
            \"catalogType\": \"iceberg-rest\",
            \"executor\": \"controller\",
            \"inputFormat\": \"parquet\",
            \"catalog.iceberg-rest.serviceType\": \"unity\",
            \"catalog.iceberg-rest.restUri\": \"$REST_URI\",
            \"catalog.iceberg-rest.warehouse\": \"$WAREHOUSE\",
            \"catalog.iceberg-rest.table.namespace\": \"$NAMESPACE\",
            \"catalog.iceberg-rest.table.tableName\": \"$TABLE\",
            \"catalog.iceberg-rest.auth.rest.token\": \"$PAT\",
            \"catalog.iceberg-rest.auth.storage.region\": \"$STORAGE_REGION\"
          }
        }
      }
    },
    \"config\": { \"inference\": { \"embeddedSchemaEnabled\": true } }
  }" > preview.json

# 3. Extract schema and table config from preview response.
jq --arg t "$TABLE" '.schema | .schemaName = $t' preview.json > schema.json
jq '.tableConfigs.offline'                        preview.json > tableConfig.json

# 4. Create the schema.
curl -sf -X POST "$CONTROLLER/schemas" \
  -H "$AUTH" -H 'Content-Type: application/json' -d @schema.json

# 5. Create the table — watcher starts the first sync automatically.
curl -sf -X POST "$CONTROLLER/tables" \
  -H "$AUTH" -H 'Content-Type: application/json' -d @tableConfig.json

# 6. (optional) Poll until sync completes.
curl -sf "$CONTROLLER/tables/${TABLE}_OFFLINE/externalTable/status" -H "$AUTH" \
  | jq -e '.status == "COMPLETED" and .segmentsUploaded == .filesDiscovered'
```

For OSS Unity Catalog, drop the `PAT` variable and the `auth.rest.token` key from both requests, and point `REST_URI` at your server (e.g. `http://<host>:8080/api/2.1/unity-catalog/iceberg`).

Once step 6 exits `0`, [verify it's queryable](#verify-its-queryable).

## Monitor onboarding

Three read-only endpoints report ingestion progress — run status, ingestion checkpoint, and source file count — and require `executor=controller` (set automatically). See [Observability](../observability) for full request and response details.

## Verify it's queryable

When `status` is `COMPLETED` and `segmentsUploaded` matches `filesDiscovered`, run a query against the broker (or the Data Portal query console) to confirm the data is live:

```sql theme={null}
SELECT count(*) FROM countries;
```

If `status` is `COMPLETED` but the count is `0`, give segments a moment to load on the servers, then recheck; if it persists, see [Troubleshooting](../troubleshooting).

***

## What's next

Now that the table is created and data is loading, these are the highest-impact follow-up steps:

1. **Add indexes for your query patterns.** Without indexes, every query scans all remote Parquet data. Add a range index on time/numeric columns, an inverted index on low-cardinality filter columns, and a bloom filter on high-cardinality ID columns. → [Indexes](../indexes)

2. **Enable caching and preload.** Set `enable.prefetch.page.cache=true` and `preload.enable=true` on the S3 tier so index data is served from local disk on repeated queries instead of re-fetched from S3. → [Data and Index Caching](../data-and-index-caching)

3. **Protect large-scan queries from OOM.** For tables that receive heavy aggregations or wide scans, enable the query OOM killer so a runaway query is killed instead of crashing the server. → [Best Practices & Configs — Query OOM protection](../best-practices-and-configs#query-oom-protection-large-scans)

4. **Monitor ongoing syncs.** Use the observability endpoints to check run status, ingestion checkpoint, and source file count after each scheduled sync. → [Observability](../observability)

***

For common questions and failures, see the [FAQ](../faq) and [Troubleshooting](../troubleshooting).
