# .twb XML Reference

Detailed reference for the `.twb` XML format underneath every Tableau workbook, the parsing recipes used to read it without a full XML library, and the wider Tableau API landscape. See the main `SKILL.md` for the editing workflow and rules this reference supports.

## Element map

A `.twb` is a single XML document. The elements that matter for dashboard-editing work, roughly in the order you'll encounter them:

### `<datasource>`

Top-level container for a connected data source (a warehouse table/view, or a synthetic one). Key attributes: `caption` (human-facing name — usually the dbt model or table name a person would recognize) and `name` (internal identifier, often `sqlproxy.<hash>` for a live SQL-backed connection — an opaque join key with no semantic meaning of its own).

One special datasource, conventionally named `Parameters`, has no `<connection>` at all — it exists purely to hold workbook-global parameter definitions (see Parameters below).

Inside a datasource: a `<connection>` block (server/database/dbname for the live connection), a `<column>` list (real fields and calculated fields), and a `<relation>` list (the SQL table(s)/view(s) the datasource is built on — `<relation table='...'>` or a custom SQL relation).

### `<column>` and `<calculation>`

A real (pass-through) field is just a `<column caption='...' name='[field_name]' datatype='...' role='...'/>`.

A calculated field is a `<column>` with a nested `<calculation>`:

```xml
<column caption='Month' name='[Calculation_9100000000000000123]' datatype='string' role='dimension'>
  <calculation class='tableau' formula='STR(MONTH([plan_date])) + &quot;-&quot; + STR(YEAR([plan_date]))' />
</column>
```

Two names matter and they are not the same thing:
- `caption` — what a Tableau user sees in the field list, shelves, and tooltips. Safe to change freely.
- `name` (the internal `[Calculation_XXXXXXXXXXXXXXXXXXXX]` identifier) — what every shelf, filter, sort, and action actually references under the hood. Never change this when renaming a field; change only the caption, and every dependent reference keeps working untouched.

**A calculated field is declared in more than one place.** It lives once in its owning datasource's `<column>` list, and again — a full copy — inside the `<datasource-dependencies>` block of every worksheet that uses it (see below). All copies must be edited identically; a partial edit leaves some worksheets on the old formula with no error from Tableau, just silently wrong output.

### `<worksheet>` → `<table>` → `<view>`

A `<worksheet name='...'>` contains a `<table>`, which contains a `<view>`. The `<view>` element is where the actual shelf/filter/format wiring for that worksheet lives:

- `<datasources>` / `<datasource-dependencies datasource='...'>` — the per-worksheet copy of every field (including calculated fields and parameters) that worksheet actually uses. If a shelf, filter, or sort references a field, that field's declaration must also appear here, or Tableau's referential integrity is broken.
- `<rows>` / `<cols>` — the shelf contents, as an expression string built from field *instance references* (see below), not bare field names.
- `<filter class='categorical'>` — worksheet-level filters. A boolean filter often reduces to `<groupfilter function='member' member='true'/>`, which is the XML equivalent of a bare `WHERE <boolean_column>` predicate. A filter can also carry `kind='hide'`.
- `<panes>` → `<pane>` → `<encodings>` — controls what's actually rendered per mark (e.g. `<text column='...'/>` for the headline number on a scalar-style worksheet).
- `<manual-sort>` → `<dictionary>` → `<bucket>` — an explicit, hand-authored sort order for a dimension's members. Watch for this: some apparent "fields" turn out to be a plain column carrying a manual-sort bucket list, not a calculated field, so a `<column>`/`<calculation>` search for them comes up empty.

### Shelf instance references

`<rows>`/`<cols>` don't reference bare field names — they reference *instances*, e.g. `[usr:Calculation_9100000000000000123:qk]` or `[sum:net_revenue:qk]`. The instance-ref syntax encodes the aggregation/role in the reference itself. A pivoted KPI table (e.g. "Measure Names" placed on rows to build a P&L-style list) gets its display order from an explicit ordered list of these instance-ref strings — reordering rows means reordering this list, not the underlying field declarations.

A shelf field can also be wrapped in a `<column-instance derivation='Sum'>` carrying a nested `<table-calc type='PctTotal'/>` (or similar). This changes what's actually displayed — a `column-instance` wrapping a plain `SUM` formula with `PctTotal` renders as a percent-of-total, not the raw sum. Always check for a wrapping `column-instance` before assuming a shelf reference shows the base field's formula untransformed.

### `<actions>`

Workbook-level interactivity (filter actions, highlight actions, URL actions, parameter actions) between worksheets/dashboards, referencing worksheets and fields by name.

### `<window class='dashboard'>` / `<dashboard>` → `<zone>`

Each dashboard is a `<window class='dashboard' name='...'>` wrapping a `<dashboard>` element, which contains a `<zones>` tree.

- **Coordinate space**: zones use a normalized `x`/`y`/`w`/`h` space from 0 to 100,000 in both axes, regardless of the dashboard's actual pixel size (a fixed-size dashboard's canvas, e.g. 1300×830px, is what that space maps onto). All layout reasoning (as in the worked example in `SKILL.md`) is done in this normalized space, then converted to pixels only for describing the result to a human.
- **`layout-flow` containers** tile their child zones edge-to-edge with no gaps and no overlap. A new zone must go in the next free slot — computed as the previous sibling's `y + h` (or `x + w` for a horizontal flow) — never at a copied zone's coordinates. If two zones in the same container overlap, Tableau does not error; it silently renormalizes the *entire* container's layout the next time the workbook loads, which can visibly shift or misalign the whole dashboard.
- **`<zone name='...'>`** is how a dashboard references the worksheet that fills that zone — dashboards don't embed worksheet content, they point at it by name. Worksheet names and the tab labels a user sees frequently differ, so resolving "which worksheet backs this dashboard tab" means reading the zone's `name` attribute, not guessing from the visible tab text.
- **`type-v2='paramctrl'`**: a zone type for a parameter control widget, e.g. `<zone type-v2='paramctrl' param='[Parameters].[Parameter 2]' mode='compact'>`. The cleanest way to add a new one is to copy an existing parameter-control zone's XML shape as a template and change the `param` reference and coordinates.

### `<devicelayouts>`

Phone/tablet layout variants live in a `<devicelayouts>` block, sibling to the base `<zones>` tree. Critically, **these reuse the same zone `id` values as the base layout** — a zone with a given id can and does appear more than once across the base layout and one or more device layouts. Any edit to a zone (moving it, changing what it displays) needs to be mirrored everywhere that same zone id reappears, or the device-layout variant silently keeps stale behavior. If a base-layout change is substantial, it can be simpler to drop the stale `<devicelayouts>` block entirely and let Tableau regenerate a default one on next save.

### Parameters datasource

Parameters are declared as `<column>` elements inside the special `Parameters` datasource (no `<connection>`):

```xml
<column caption='View by' name='[Parameter 2]' param-domain-type='list' datatype='string'>
  <calculation class='tableau' formula='&quot;Monthly&quot;' />
  <members>
    <member value='&quot;Monthly&quot;' />
    <member value='&quot;Calendar Week&quot;' />
  </members>
</column>
```

Parameters are **workbook-global and sequentially enumerated** (`[Parameter 1]`, `[Parameter 2]`, ...) — not scoped to a worksheet. Adding a new parameter means first counting every existing one to find the next free number, then using that number consistently everywhere the new parameter is referenced (worksheet `datasource-dependencies`, calculated-field formulas, parameter-control zones).

Any worksheet that uses a parameter (directly on a shelf, or indirectly via a calculated field that references it) needs the parameter declared in that worksheet's own `<datasource-dependencies datasource='Parameters'>` block too, same as any other field dependency.

## Parsing recipes

No XML library is required for recon on files up to a few megabytes — plain text tools get you further, faster, and don't choke on encoding quirks in formula text.

**Zip inspection, always first:**

```bash
unzip -l "Workbook.twbx"                      # list members — inner .twb filename varies, never guess it
unzip -p "Workbook.twbx" "Exact Name.twb" > scratch/workbook.twb
```

One real example: a workbook archive contained an inner file called `DM Competitor Reporting (4).twb` (a stale auto-numbered save) — nothing about the archive's own name predicted this.

**Tag-opening inventory** via `grep -oE`, to build a map before deciding what to touch:

```bash
grep -o "<worksheet name='[^']*'" workbook.twb
grep -o "<datasource caption='[^']*'[^>]*name='[^']*'" workbook.twb | sort -u
grep -oE "<relation[^>]*table='[^']*'[^>]*" workbook.twb | sort -u
grep -o "<window class='dashboard'[^>]*name='[^']*'" workbook.twb
```

**Block isolation and field extraction** with a small inline `uv run python` script:

```python
import re, html

xml = open("scratch/workbook.twb", encoding="utf-8", newline="").read()

# isolate one worksheet's block
m = re.search(r"<worksheet name='Performance Overview'.*?</worksheet>", xml, re.S)
if m is None:
    raise ValueError("Worksheet 'Performance Overview' not found in workbook")
block = m.group(0)

# extract the calculated fields declared inside that worksheet's block
for m in re.finditer(
    r"<column caption='([^']*)'[^>]*name='(\[[^\]]*\])'[^>]*>\s*"
    r"<calculation class='tableau' formula='([^']*)'",
    block,
):
    caption, name, formula = m.group(1), m.group(2), html.unescape(html.unescape(m.group(3)))
    print(caption, name, formula)
```

Call `html.unescape()` **twice**. Formula text is commonly double-escaped in the source file (an already-escaped `&amp;apos;` rather than a plain `&apos;`), and a single unescape pass leaves visible entity fragments in the decoded formula.

**Fallback when a `<column>` search returns nothing**: some "fields" are actually a plain column with a `<manual-sort><dictionary><bucket>` list attached, not a calculated field. Fall back to a plain substring search for the caption text and inspect the surrounding XML by hand:

```python
idx = xml.find("Some Field Caption")
print(xml[idx - 200: idx + 500])
```

**What to extract during recon, in order of usefulness:**

- worksheet and dashboard name inventories (what exists, before deciding what changes)
- datasource captions plus their `sqlproxy.<hash>` internal names (caption ≈ the warehouse/dbt model name; the hash is an opaque join key)
- parameters (`param-domain-type='list'`, current `value`/`<members>`)
- calculated fields (`<calculation class='tableau' formula='...'>`, with caption and internal name)
- worksheet filters (`<filter class='categorical'>`; `kind='hide'`; `<groupfilter function='member' member='true'/>` = a bare boolean predicate)
- rows/cols shelves and pane encodings (`<panes><pane><encodings><text column='...'/>` shows the exact field driving a headline number)
- table calcs (`<column-instance derivation='Sum'><table-calc type='PctTotal'/>` — a plain SUM formula can still render as a percent-of-total; always check for the wrapping `column-instance`)

Dashboards reference worksheets by name via `<zone name='...'>` — worksheet names and the tab labels a user sees frequently diverge, so map dashboard tabs to worksheets through the zone tree, not the visible label.

## Escaping reference

| Context | Escape |
|---|---|
| Attribute delimiter | single quotes throughout (`name='...'`, not double quotes) |
| Literal single quote inside a formula/attribute value | `&apos;` |
| Literal double quote inside a formula or number-format string | `&quot;` |
| Embedded newline inside a formula | `&#10;` |
| Angle brackets inside a formula or caption | `&lt;` / `&gt;` |
| Line endings of the file itself | CRLF — open with `newline=''` in Python, normalize any newly authored fragment to CRLF before splicing it in, and verify the final CRLF count matches the pre-edit file |

Example number-format strings using this escaping: a EUR currency format is `c#,##0&quot;€&quot;;-#,##0&quot;€&quot;`, a one-decimal percentage is `p0.0%`.

## Gotchas in depth

**Calculated-field duplication.** A calc is declared once in its owning datasource's `<column>` list and again inside every worksheet's `<datasource-dependencies>` block that references it. In one real edit, a `Month` calculated field had exactly 7 copies (1 datasource + 6 worksheets); every edit to its formula needs to hit all 7, verified with a scripted assertion (`assert txt.count(anchor) == 7`) both before and after the change. Missing a copy produces no error — just a worksheet silently left on the old formula.

**Zone tiling and the silent-renormalization failure mode.** `layout-flow` dashboard containers require their child zones to tile edge-to-edge with zero overlap. If a newly inserted zone overlaps an existing one — most often because its coordinates were copied from another zone instead of computed as the next free slot — Tableau does not raise an error. It silently renormalizes the *entire* container's layout the next time the file is opened. The visible symptom in one real case was the whole dashboard becoming left-aligned in the browser where it had previously been centered — nothing about the symptom pointed directly at "a zone overlaps," which is why an explicit zero-overlap assertion across all sibling zones is part of the validation checklist, not just a well-formedness check.

**CRLF corruption.** Tableau writes `.twb` files with CRLF line endings. A naive Python text-edit pass (opening without `newline=''`, or splicing in a Python multi-line string literal with bare `\n`) silently converts some or all line endings to LF. The fix is opening with `newline=''`, always normalizing any newly authored fragment to CRLF before splicing, and asserting the final file's CRLF count against the original before re-zipping. A normalized (CRLF-aware) diff against the backup should show only the intentionally changed lines.

**Well-formed XML is not sufficient for Tableau to load the file.** Tableau enforces a strict internal element-order content model per parent element type — functionally like a DTD, even though `.twb` files don't ship one. A real failure from a generated workbook: `element 'shelf-sorts' is not allowed for content model '(datasources?,mapsources?,datasource-dependencies*,filter,((computed-sort)|(manual-sort)|(natural-sort)|(alphabetic-sort)),perspectives,slices?,aggregation)'`. The `<shelf-sorts>` element itself was syntactically valid, correctly nested, and parsed cleanly with `xml.etree.ElementTree` — it was simply in the wrong position relative to its siblings for that parent's content model. `ElementTree.parse()` succeeding is necessary but not sufficient evidence that Tableau Desktop will load the file. The reliable check is diffing any newly authored block against a known-good, Tableau-authored block of the same element type and confirming the sibling ordering matches, rather than assuming general XML validity transfers.

**Hidden sheets.** A worksheet's "hidden" state only holds if that worksheet is placed on at least one dashboard via a `<zone>`. An orphaned hidden worksheet — referenced by no dashboard — can reassert itself as visible in the workbook's sheet list.

## Datasource repointing mechanics

If a workbook's published Tableau Cloud/Server datasource has been deleted, renamed, or needs to point at a different published datasource, repointing correctly means rewriting **four** separate locations consistently — missing any one leaves the workbook partially pointed at the old datasource:

1. The `<connection>` block's `dbname` (and related connection attributes).
2. The `repository-location` element's `id` and `derived-from` attributes.
3. Every `<family>` tag in the datasource's field metadata.
4. The cached copy of the published datasource's schema embedded in the connection block.

Publishing back to Tableau Cloud/Server afterward has its own separate failure mode: a published datasource's display *name* and its internal *content URL* can diverge (Tableau appends a numeric suffix when a chosen name is already taken by another asset). A browser-based/API publish requires an exact content-URL match with no interactive reconciliation step, unlike a Desktop publish, which can prompt to resolve the mismatch interactively. If a browser-based publish fails on a content-URL mismatch, the options are: publish from Tableau Desktop instead (where the mismatch can be resolved interactively), or download the target `.tds` to read its ground-truth content URL and patch the workbook XML to match it byte-exact. Tableau Cloud/Server sign-in is typically SSO-gated, which blocks browser automation from completing a login — if browser automation hits an SSO wall, stop and hand the browser-side step to a human rather than attempting a workaround.

## API landscape

Classic Tableau (Cloud/Server, not Tableau Next) has no first-party dashboards-as-code path. What exists:

| API / tool | Scope | Notes |
|---|---|---|
| **Document API** (Python) | Programmatically edit `.twb`/`.twbx` XML — calculated fields, connections, formatting — without Desktop | The closest thing to an official as-code path; a structured version of the manual XML surgery this skill documents. Prefer it over raw string edits if it's available in the project's environment. |
| **REST API** | Content management — publish, refresh, permissions, site admin | CRUD over workbooks/datasources/users. Write operations on external assets need a Data Management license. |
| **Metadata API** | Read-only GraphQL — lineage, schema, impact analysis | No writes; useful for answering "what depends on this field/table" without opening workbook XML. |
| **VizQL Data Service (VDS)** | Queries a published datasource, returns JSON, bypasses rendering entirely | Tableau 2025.1+; has a Python SDK; tenant-level hourly call caps apply as of 2026.1. |
| **tabcmd** | Classic CLI for scripted publish/refresh/site-admin | A newer Python-based reimplementation also exists. |
| **MCP** | No first-party general-purpose MCP server for classic Tableau Cloud/Server | Community servers exist (e.g. hetpatel-11/Tableau-MCP, LokiMCPUniverse/tableau-mcp-server) but are unofficial. Salesforce's official MCP server is scoped to Tableau Next semantic models (which require Salesforce Data Cloud) and doesn't apply to classic workbooks. |

Versioning notes: `.twb`/`.tds` are plain XML and diff/merge fine in git; `.twbx`/`.tdsx` are binary zip archives and do not — unpack them for any git-tracked workflow. Tableau Cloud/Server's built-in revision history caps at 10 versions with no changelog, so a git-backed backup (e.g. via the Tableau Server Client Python library) is the only way to keep a longer, diffable history.
