---
name: tableau-dashboards
description: Edit, create, and debug Tableau dashboards by working on the underlying .twb/.twbx workbook XML — use when editing Tableau dashboards, creating worksheets, dashboards, parameters, or calculated fields, reading or parsing .twb/.twbx workbook XML, translating a Tableau KPI into a dbt model, or debugging wrong/missing numbers that show up in Tableau.
---

# Tableau Dashboards

## Overview

Classic Tableau has no dashboards-as-code. There is no declarative format, no first-party CI/CD, and no way to "just describe" a dashboard change the way you would in dbt or Lightdash. A `.twbx` packaged workbook is a zip archive around a `.twb` XML file (plus static assets like logo images, and sometimes a `.hyper` data extract). Editing a Tableau dashboard from Claude Code means editing that XML directly and surgically, validating the result structurally (well-formed XML, zip integrity, referential integrity between shelves/filters and declared fields, zero overlapping layout zones), and then handing off. Final visual verification is always a human opening the workbook in Tableau Desktop — Claude cannot render Tableau's canvas and has no programmatic way to confirm "it looks right."

This skill assumes you are working against a local workbook file, not Tableau Server/Cloud. See `references/twb-xml-reference.md` for the full XML element map and parsing recipes, and `references/tableau-dbt-workflows.md` for translating Tableau logic into dbt and debugging numeric discrepancies.

## Triage: which kind of ticket is this?

Before touching any XML, work out which of three situations you're in:

1. **Pure workbook edit** — the dbt model feeding the affected worksheet already has the grain and columns the ticket needs (e.g. a daily-grain model can be re-aggregated to monthly or weekly inside Tableau for free). This is the common case: edit the `.twb` XML, no dbt/repo change, no PR.
2. **Grain or data is missing** — the ticket needs a breakdown, time grain, or column that the feeding dbt model doesn't expose (e.g. a monthly model needs to become a weekly long-format model). This is a dbt ticket first: check whether the missing detail already exists upstream and just isn't threaded through the model (a normal dbt change) or isn't captured anywhere in the warehouse (a data-collection gap to surface, not a model change), then run the dbt change through the project's normal model/PR/review workflow — that part is outside this skill's scope. Come back and do the Tableau XML edit only after the new columns are merged and built.
3. **"The numbers are wrong / missing in Tableau"** — almost always dbt-side debugging, not a workbook edit. Tableau just renders what the warehouse model computes; if a KPI is off, the fastest path is comparing the feeding dbt models against each other and against git history, not staring at worksheet XML. Go straight to `references/tableau-dbt-workflows.md` for this case. The only workbook read that's warranted here is a narrow recon pass (grep the affected worksheet's datasource `caption` out of the `.twb`) to identify which dbt model feeds it — no XML surgery, no editing.

Getting this triage wrong wastes the most time: XML-surgery skills applied to case (2) or (3) produce a workbook that faithfully renders the wrong (or absent) data.

## The working copy & data safety

Always work on a dedicated copy of the workbook that the human provides — never the "real" workbook the human still has open in Desktop, since Desktop would overwrite your edits on its next save. The human points you at the copy, wherever it lives and whatever it's called; a common Gemma convention is a `<workbook_name>_claude.twbx` copy in the local Tableau Repository Workbooks folder (on WSL2: `/mnt/c/Users/<windows_username>/Documents/My Tableau Repository/Workbooks/`), but the suffix and location are conventions, not requirements — take whatever path you are given, and if it's unclear whether the file is a dedicated copy, ask before editing.

Before touching the archive:

- **Always `unzip -l <workbook>.twbx` first**, before extracting anything. This lists the archive members without touching their contents.
- If the listing contains a `.hyper` file or any other data extract, **do not read it** — that is client business data, not workbook definition, and reading it is out of scope for this skill. Proceed only with the `.twb` XML and static assets (images).
- **Back up the original `.twbx` before editing** (a plain copy alongside the working file is enough) so a diff against the pre-edit state is always available if something needs to be root-caused later.
- The inner `.twb` filename inside the archive is not guaranteed to match the archive's own name — never guess it, always read it off the `unzip -l` listing.

## Reading a workbook

No XML library is needed for recon, even on multi-megabyte `.twb` files — plain text tools get you further faster:

- **Tag-opening inventory** via `grep -oE`, to build a map of what exists before deciding what to change:
  ```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** with small inline `uv run python` scripts using `re.search(..., re.S)` to grab one worksheet/datasource block, then `re.finditer` inside it for columns, filters, or shelves. Call `html.unescape()` **twice** — formula text is commonly double-escaped, and a single unescape leaves entities like `&amp;apos;` behind.
- **Caption vs. internal name**: every field has a human-facing `caption` (what a user sees in Tableau) and a stable internal `name='[Calculation_XXXXXXXXXXXXXXXXXXXX]'` (what every shelf, filter, sort, and action actually references). Renaming a field means changing the caption, never the internal name.
- **`sqlproxy` captions map to warehouse/dbt model names**: a datasource's `caption` is usually the dbt model or table name a human would recognize; the accompanying `sqlproxy.<hash>` internal name is an opaque join key with no semantic meaning.
- **Table-calc wrappers change what's displayed**: a plain `SUM` formula wrapped in a `<column-instance derivation='Sum'>` with a nested `<table-calc type='PctTotal'/>` renders as a percent-of-total, not the raw sum — always check for a wrapping `column-instance` before assuming a shelf shows the base formula.
- **Manual-sort buckets can masquerade as fields**: some apparent "fields" are actually plain columns with a `<manual-sort><dictionary><bucket>` list attached, not calculated fields — if a `<column>`/`<calculation>` search comes up empty, fall back to a plain string search for the caption and inspect the surrounding XML.
- **Dashboards reference worksheets via `<zone>`**, and worksheet names frequently differ from the tab names a user sees — always resolve which worksheet backs a dashboard tab by reading the dashboard's `<zone name='...'>` entries, not by guessing from the tab label.

## Editing workflow

1. **Recon existing patterns first** — map datasources, worksheets, dashboards, parameters, and relevant calculated fields in the target workbook, and in any precedent workbook that already solves a similar problem (copy known-good XML shapes rather than inventing structure from scratch).
2. **Map every occurrence and count them** — a calculated field, parameter reference, or formula fragment typically appears more than once (see the editing rules table below). Find and count every copy before writing any edit.
3. **Make anchor-based string edits in small Python scripts**, not full DOM parse-mutate-serialize round trips. For every replacement, assert the anchor's occurrence count matches what you counted in step 2 (`assert txt.count(anchor) == expected_count`) before performing the replacement — fail loudly rather than silently apply a partial or wrong-count edit.
4. **Validate** against the checklist below.
5. **Re-zip with Python's `zipfile` module** (the `zip` CLI is not installed in this environment), preserving every other archive member — images and any other assets — untouched.
6. **Overwrite the working copy** in place.
7. **Hand off for a human visual check in Tableau Desktop**, explicitly listing what changed and what to look at (which worksheet/dashboard, which control, what the expected before/after difference is) — and every assumption you made where the ticket was ambiguous (which worksheet you identified as the target, exact label/caption wording, locale conventions), stated as assumptions to confirm rather than buried in the change description.

## Editing rules quick reference

| Gotcha | Why it bites | Fix |
|---|---|---|
| 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 uses it | Edit every copy identically; assert `txt.count(anchor) == expected` both before and after the edit |
| Zone tiling | A new zone in a `layout-flow` dashboard container needs the next free slot; if it overlaps an existing zone, Tableau silently renormalizes the *entire* dashboard layout (symptom: everything shifts, e.g. the whole dashboard becomes left-aligned) | Compute the new zone's `y` as `previous_zone.y + previous_zone.h` — never copy an existing zone's coordinates — and assert zero overlaps across all sibling zones before re-zipping |
| CRLF preservation | Tableau writes `.twb` with CRLF line endings; naive text I/O in Python silently converts them to LF | Open files with `newline=''`, normalize any newly authored XML fragment to CRLF before splicing it in, and assert the final CRLF count matches the original |
| XML escaping | Formulas, captions, and number-format strings embed literal quotes, newlines, and angle brackets | `&apos;` for literal single quotes in formulas, `&quot;` for double quotes (including inside number-format strings), `&#10;` for embedded newlines, `&lt;`/`&gt;` for angle brackets; attributes are single-quote delimited |
| Parameters are workbook-global and enumerated | Parameters are numbered sequentially (`[Parameter 1]`, `[Parameter 2]`, ...) across the whole workbook, not scoped per worksheet | Count every existing parameter before adding a new one — the new one takes the next number, and every reference to it must be consistent |
| Device layouts reuse zone ids | `<devicelayouts>` variants (phone/tablet) reference the same zone `id` values as the base dashboard layout | Any edit to a zone in the base layout must be mirrored wherever that same zone id reappears under `<devicelayouts>`, or the block should be dropped and let Tableau rebuild it |
| Edit formulas in place | Every dependent artifact — sorts, filters, actions, tooltips — references a calc by its stable internal `name='[Calculation_...]'`, never its caption | Change the `caption` and/or the `formula` attribute value on the existing `<column>`/`<calculation>` element; never delete and recreate a calc, or every dependent reference breaks |
| Well-formed XML ≠ loadable | Tableau enforces a strict internal element-order content model per parent type (like a DTD), independent of general XML well-formedness. Real failure seen in practice: `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 element was syntactically valid XML in the wrong position | Diff any newly authored block against a known-good Tableau-authored block of the same element type; don't assume `xml.etree.ElementTree` parsing successfully means Tableau Desktop will load the file |
| Hidden sheets only stay hidden if placed on a dashboard | A worksheet marked hidden can resurface as visible if it isn't referenced by any dashboard `<zone>` | Confirm every sheet you intend to keep hidden is placed on at least one dashboard |

## Validation checklist

For pure workbook edits (triage case 1), validation is structural plus the human Desktop check — data-value validation belongs to KPI-translation and debugging work (see `references/tableau-dbt-workflows.md`). Run all of these before handing a workbook back, in this order:

- [ ] **XML well-formedness** — parse the edited `.twb` with `xml.etree.ElementTree.parse()`; this catches malformed edits but not content-model ordering errors (see the well-formed-≠-loadable row above)
- [ ] **Replacement-count assertions** — every anchor-based edit's actual occurrence count matches what recon predicted
- [ ] **Zone-overlap assertion** — no two sibling zones in any dashboard or device-layout container overlap in the 0–100,000 coordinate space
- [ ] **CRLF count** — the final file's line-ending count matches the pre-edit file (no silent CRLF→LF conversion)
- [ ] **Zip integrity** — re-open the re-zipped `.twbx` and confirm all expected members (the `.twb` plus original assets) are present and readable
- [ ] **Diff against the backup** — a normalized diff (CRLF-aware) of old vs. new `.twb` shows only the intended lines changed, nothing incidental
- [ ] **Human Desktop check** — hand off with an explicit list of what changed and what to look at; this is the only step that confirms the dashboard actually *looks* right, and it cannot be automated

## Worked example: month ↔ calendar-week switch

A real ticket from a client engagement, reproduced as it was pasted at the start of the conversation (only the Windows username in the path is redacted):

> Screenfilter
>
> Hey team,
>
> In the following screen, I would like to be able to switch between the monthly and calendar-week views.
> As a member of the Controlling department, this will allow me to provide C-level management with a more detailed picture of the company's current performance and critical developments.
>
> C:\Users\<windows_username>\Documents\My Tableau Repository\Workbooks
> Actual Vs. Plan Finance Report_claude.twbx

The ticket referenced an embedded screenshot that did not come through in the conversation — Claude flagged this gap explicitly during clarification rather than guessing at the missing layout, then later reconstructed the dashboard's actual layout purely from the workbook XML's zone coordinates (a `x/y/w/h` block in the 0–100,000 normalized space, mapped onto the dashboard's fixed 1300×830px canvas) and rendered it as ASCII art to confirm its own understanding with the user:

```
┌──────────────────────────────────────────────┬────────────────┐
│  ACTUAL VS. PLAN FINANCE REPORT        title │  client logo   │
├──────────────────────────────────────────────┼────────────────┤
│  NET REVENUE                                 │ ▾ Date         │
│  ┌────────────┐  ┌─────────────────────────┐ │ ▾ Shopify Shop │
│  │ month rows │  │ D2C / Retail breakdown  │ │ ▾ D2C/Retail   │
│  │ 7-2026 ... │  │                         │ │ ▾ Actuals vs.  │
│  └────────────┘  └─────────────────────────┘ │ ▾ View by  ←new│
│  MARKETING COSTS ...                         │                │
│  CM ...                                      │                │
└──────────────────────────────────────────────┴────────────────┘
```

The feeding dbt model was already daily grain, so this was a pure Tableau-side change (triage case 1 above) — no dbt ticket needed. The core of the fix was a new list parameter plus a formula swap on the existing `Month` calculated field, switching on the parameter value:

```
IF [Parameters].[Parameter 2] = 'Calendar Week'
THEN 'CW ' + STR(DATEPART('iso-week', [plan_date])) + '-' + STR(DATEPART('iso-year', [plan_date]))
ELSE STR(MONTH([plan_date])) + '-' + STR(YEAR([plan_date]))
END
```

The key detail: pairing `DATEPART('iso-week', ...)` with `DATEPART('iso-year', ...)` — not calendar `YEAR()` — so weeks that straddle a year boundary label correctly.

The same parameter-switch shape works for any two expressions, not just two formats of one field — e.g. toggling a table between two real measure columns is `IF [Parameters].[Parameter <n>] = '<label_a>' THEN [gross_revenue] ELSE [net_revenue] END` with the same parameter, duplication, and paramctrl mechanics as below.

What this took, end to end: a new list parameter (`param-domain-type='list'`, with the two allowed string values as `<members>`); the formula swap above applied identically to all 7 copies of the `Month` calculated field (1 in the owning datasource, 6 in worksheet `datasource-dependencies` blocks); a caption rename from "Month" to "Period" while keeping the internal `[Calculation_...]` name unchanged, so every sort/filter/action still wired correctly; and a new `paramctrl` zone for the parameter control, placed at the next free slot in the right-hand filter panel.

Two real corrections came back from the human after the first pass: the new parameter-control zone had been placed at the same coordinates as an existing control, which triggered Tableau's silent layout renormalization and left the whole dashboard left-aligned in the browser — fixed by recomputing the zone's slot as the previous sibling's `y + h` and asserting zero overlaps; and the week label read "KW" (the locale-typical abbreviation Claude defaulted to when asking which week format to use) where the client actually wanted "CW" — a reminder that locale-tied defaults on ambiguous formatting questions should be confirmed explicitly, not assumed.

## Creating new views/dashboards

Building a new worksheet or dashboard from scratch in the XML is riskier than editing an existing one — there's no existing anchor to edit in place, so the whole block has to be authored correctly the first time. The workflow that held up in practice:

- **Copy known-good XML patterns from precedent workbooks** rather than inventing structure — find a workbook that already has a similar filter, shelf layout, or KPI-row pattern and reuse its shape (attribute names, nesting, formatting conventions) rather than writing a new element tree from the spec alone.
- **Shelves reference field instances, not bare fields** — `<rows>`/`<cols>` shelves use instance-reference syntax like `[usr:Calculation_...:qk]` or `[sum:net_revenue:qk]`; a display order for a KPI table (e.g. pivoted "Measure Names" rows) is controlled by an explicit ordered list of these instance-ref strings, not by the order fields happen to be declared in.
- **Number-format strings need the same escaping as formulas** — e.g. a currency format is `c#,##0&quot;€&quot;;-#,##0&quot;€&quot;`, a percentage is `p0.0%`.
- **Publishing pitfalls**, if the workflow includes publishing back to Tableau Cloud/Server rather than just editing a local file:
  - A published datasource's display *name* and its internal *content URL* can diverge (Tableau appends a suffix when a name is already taken) — a browser-based publish requires an exact content-URL match with no interactive reconciliation, unlike a Desktop publish which can prompt to resolve it.
  - If a workbook's published datasource has been deleted or renamed server-side, repointing it means rewriting **four** separate locations consistently: the connection's `dbname`, the `repository-location` `id`/`derived-from` pair, every `<family>` tag in field metadata, and the cached copy of the published datasource's schema inside the connection block.
  - Tableau Cloud/Server sign-in is typically SSO-gated, which blocks browser automation from completing a login flow — if this happens, stop and ask the human to do the browser-side step rather than trying to work around SSO.

## Cross-references

- `references/twb-xml-reference.md` — full `.twb` XML element map, all parsing recipes, escaping rules, and the Tableau API landscape.
- `references/tableau-dbt-workflows.md` — translating Tableau KPI logic into dbt, the two-layer validation rule, and the debugging-wrong-numbers workflow.
