Get certified for free when you join Fabric Data Days 2026 and dive into Fabric, Power BI, SQL, AI, and other essential data skills.
Join nowData Days is here! Join us now for 60+ days of learning, challenges, and connection. Learn more
Your file has been submitted successfully. We’re processing it now - please check back in a few minutes to view your report.
05-15-2026 01:15 AM - last edited 05-15-2026 01:16 AM
The following two scripts check for custom visuals in use for the whole tenant and lists them.
Lightweight alternative for individual reports
%pip install semantic-link-labs --quiet
import sempy_labs as labs
from sempy_labs.report import ReportWrapper
cv = ReportWrapper(report="Report Name", workspace="Workspace Name").list_custom_visuals()
display(cv)
This script checks for custom visuals in use for the whole tenant and lists them.
%pip install semantic-link-labs --quiet
import sempy_labs as labs
from sempy_labs.report import ReportWrapper
cv = ReportWrapper(report="Report Name", workspace="Workspace Name").list_custom_visuals()
display(cv)<p> </p><p>For all reports in current or all workspaces the following notebook works:<li-code lang="markup"># Custom Visuals Inventory across a Power BI / Fabric Workspace
**Author:** Alexander Korn — Solution Engineer Data Platform, Microsoft
**Last updated:** 2026-05-15
**Runtime:** Microsoft Fabric Notebook (Python)
**Dependencies:** [`semantic-link-labs`](https://github.com/microsoft/semantic-link-labs)
---
## Purpose
Governance teams frequently need to answer a deceptively simple question:
> **Which custom (third-party / AppSource / organizational) visuals are actually used in our Power BI reports — and where?**
This information is essential for:
- **Tenant governance** — admins disabling or restricting custom visuals need an impact assessment first.
- **Security & compliance** — custom visuals are arbitrary code; you should know what is in production.
- **Lifecycle management** — identifying unused or deprecated visuals before cleanup.
- **Migration planning** — moving reports between tenants or to Fabric requires knowing every visual dependency.
## What this notebook does
It scans **every Power BI report in a given workspace** (or list of workspaces) and produces a tidy table:
| Workspace | Report | Custom Visual Name | Custom Visual Display Name |
|-----------|--------|--------------------|----------------------------|
Under the hood it uses `sempy_labs.report.ReportWrapper.list_custom_visuals()` from semantic-link-labs, which parses the report definition (PBIR) and returns the visuals registered in the report.
> ℹ️ Reports stored in the legacy single-file PBIX layout are skipped — see **Notes & limitations** at the bottom for how to convert them.
## How to use
1. Attach this notebook to any Lakehouse in your target workspace (no tables are written — the lakehouse attachment is only required by the Fabric Python runtime).
2. Run the install cell once per session (or bake `semantic-link-labs` into a custom Fabric environment).
3. Set `MODE` (and optionally `WORKSPACES`) in the configuration cell.
4. Run all cells. The final cell renders the inventory table and a few aggregations.
---
## 1. Install dependencies
Run this once per Fabric session. To avoid the install on every run, add `semantic-link-labs` to a [custom Fabric environment](https://learn.microsoft.com/fabric/data-engineering/create-and-use-environment) and attach it to the notebook.
%pip install semantic-link-labs --quiet
## 2. Configuration
Pick the **scan mode**:
- `"current"` — scan only the workspace this notebook is attached to.
- `"list"` — scan the workspaces named in `WORKSPACES`.
- `"all"` — scan every workspace the executing identity can see (slow on large tenants).
# Scan mode: "current" | "list" | "all"
MODE = "current"
# Used only when MODE == "list" — workspace names or IDs.
WORKSPACES: list[str] = ["Demo"]
## 3. The function
`list_custom_visuals_in_workspace(workspace)` returns a tidy DataFrame with one row per (report, custom visual) pair. It silently skips paginated reports and any report it can't parse, logging the reason.
import traceback
import pandas as pd
import sempy.fabric as fabric
from sempy_labs.report import ReportWrapper
def _resolve_workspace(workspace: str | None) -> str:
"""Resolve workspace name — fall back to the notebook's current workspace."""
if workspace:
return workspace
ws_id = fabric.get_notebook_workspace_id()
return fabric.resolve_workspace_name(ws_id)
def list_custom_visuals_in_workspace(workspace: str | None = None) -> pd.DataFrame:
"""Return all custom visuals used by Power BI reports in a workspace.
Columns: Workspace, Report, Custom Visual Name, Custom Visual Display Name.
"""
ws = _resolve_workspace(workspace)
try:
reports = fabric.list_reports(workspace=ws)
except Exception:
print(f" · could not list reports in '{ws}':")
traceback.print_exc()
return pd.DataFrame(columns=["Workspace", "Report", "Custom Visual Name", "Custom Visual Display Name"])
type_col = "Report Type" if "Report Type" in reports.columns else None
if type_col:
reports = reports[reports[type_col].isin(["PowerBIReport", "Power BI Report"])]
name_col = "Name" if "Name" in reports.columns else "Report Name"
rows = []
for _, r in reports.iterrows():
report_name = r[name_col]
try:
rw = ReportWrapper(report=report_name, workspace=ws)
cv = rw.list_custom_visuals()
except Exception as e:
print(f" · skipping '{report_name}': {type(e).__name__}: {e}")
continue
if cv is None or cv.empty:
continue
used_col = next((c for c in cv.columns if c.lower().startswith("used")), None)
used = cv[cv[used_col] == True] if used_col else cv # noqa: E712
for _, v in used.iterrows():
rows.append({
"Workspace": ws,
"Report": report_name,
"Custom Visual Name": v.get("Custom Visual Name"),
"Custom Visual Display Name": v.get("Custom Visual Display Name"),
})
return pd.DataFrame(
rows,
columns=["Workspace", "Report", "Custom Visual Name", "Custom Visual Display Name"],
)
def list_custom_visuals_multi(workspaces: list[str | None]) -> pd.DataFrame:
"""Run the inventory across multiple workspaces and concatenate the results."""
frames = []
for ws in workspaces:
print(f"Scanning workspace: {ws or '<current>'}")
frames.append(list_custom_visuals_in_workspace(ws))
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
## 4. Report format inventory (PBIR vs. PBIRLegacy)
Lists every Power BI report in the configured workspace(s) along with its **storage format** (`PBIR` or `PBIRLegacy`). Reports flagged as `PBIRLegacy` will be skipped by the custom-visuals scan in section 5.
This uses the Power BI REST API `format` field directly — the same signal that [`sempy_labs.report.upgrade_to_pbir`](https://semantic-link-labs.readthedocs.io/en/stable/sempy_labs.report.html) reads. To actually upgrade, see the tip at the bottom of the cell.
The same `MODE` / `WORKSPACES` configuration applies.
from sempy.fabric import FabricRestClient
_client = FabricRestClient()
def list_report_formats(workspace: str | None = None) -> pd.DataFrame:
"""List Workspace, Report, Format ('PBIR' / 'PBIRLegacy') for every PBI report.
Uses the Power BI REST API `format` field — same signal that
``sempy_labs.report.upgrade_to_pbir`` relies on, but read-only.
"""
ws_name = _resolve_workspace(workspace)
ws_id = fabric.resolve_workspace_id(ws_name)
resp = _client.get(f"/v1.0/myorg/groups/{ws_id}/reports").json()
rows = [
{"Workspace": ws_name, "Report": r["name"], "Format": r.get("format", "Unknown")}
for r in resp.get("value", [])
if r.get("reportType") in (None, "PowerBIReport")
]
return pd.DataFrame(rows, columns=["Workspace", "Report", "Format"])
# Reuse the same MODE / WORKSPACES configuration as the rest of the notebook.
def _targets_from_config() -> list[str | None]:
if MODE == "current":
return [None]
if MODE == "list":
return list(WORKSPACES)
if MODE == "all":
all_ws = fabric.list_workspaces()
if "Type" in all_ws.columns:
all_ws = all_ws[all_ws["Type"] == "Workspace"]
name_col = "Name" if "Name" in all_ws.columns else "Workspace Name"
return all_ws[name_col].tolist()
raise ValueError(f"Unknown MODE '{MODE}'.")
formats = pd.concat(
[list_report_formats(ws) for ws in _targets_from_config()],
ignore_index=True,
)
if formats.empty:
print("No Power BI reports found.")
else:
counts = formats["Format"].value_counts().to_dict()
print("Report format summary — " + ", ".join(f"{k}: {v}" for k, v in counts.items()))
display(formats)
# Tip: to convert all PBIRLegacy reports automatically, run:
# import sempy_labs as labs
# labs.report.upgrade_to_pbir(workspace="Demo")
## 5. Run the inventory
Resolves the workspace list from `MODE` and runs the scan. The result is always stored in `df`.
def _resolve_workspace_list(mode: str, workspaces: list[str]) -> list[str | None]:
"""Translate MODE + WORKSPACES into the actual list of workspaces to scan."""
if mode == "current":
return [None]
if mode == "list":
if not workspaces:
raise ValueError("MODE='list' requires at least one entry in WORKSPACES.")
return list(workspaces)
if mode == "all":
all_ws = fabric.list_workspaces()
if "Type" in all_ws.columns:
all_ws = all_ws[all_ws["Type"] == "Workspace"]
name_col = "Name" if "Name" in all_ws.columns else "Workspace Name"
return all_ws[name_col].tolist()
raise ValueError(f"Unknown MODE '{mode}'. Use 'current', 'list', or 'all'.")
targets = _resolve_workspace_list(MODE, WORKSPACES)
print(f"MODE='{MODE}' → scanning {len(targets)} workspace(s).\n")
df = list_custom_visuals_multi(targets)
if df.empty:
print("\nNo custom visuals found.")
else:
print(f"\nFound {len(df)} (report, custom visual) pairs across {df['Workspace'].nunique()} workspace(s) and {df['Report'].nunique()} report(s).")
display(df)
## 6. Quick aggregations
A few common slices the governance team typically wants right after the raw list.
if not df.empty:
# Most-used custom visuals
top_visuals = (
df.groupby("Custom Visual Display Name")
.agg(Reports=("Report", "nunique"), Workspaces=("Workspace", "nunique"))
.sort_values("Reports", ascending=False)
.reset_index()
)
display(top_visuals)
# Reports with the most custom visuals
heavy_reports = (
df.groupby(["Workspace", "Report"])
.agg(CustomVisuals=("Custom Visual Display Name", "nunique"))
.sort_values("CustomVisuals", ascending=False)
.reset_index()
)
display(heavy_reports)
else:
print("No data to aggregate — result set is empty.")
---
## Notes & limitations
### Reports must be stored in PBIR format
This notebook reads each report's enhanced report definition (PBIR / `definition.pbir`) via the Fabric REST API. Reports still stored in the legacy single-file PBIX layout cannot be parsed by `ReportWrapper` — they are flagged as `Legacy (PBIX)` in section 4 and **skipped with a `NotImplementedError`** in section 5.
**Two ways to convert a report to PBIR:**
- **Power BI Service (Edit in browser)** — open the report in the service, click **Edit**, make any tiny change (or none), and **Save**. The service automatically rewrites the report to PBIR on save. Often the fastest option for bulk conversion.
- **Power BI Desktop** — open the report, enable **File → Options → Preview features → "Power BI Project (.pbip) save format"** and **"Store reports using enhanced report format (PBIR)"**, then republish.
PBIR is the default for any report authored natively in Fabric.
### Other notes
- Only **Power BI reports** are inspected — paginated reports are skipped automatically.
- Detection relies on visuals registered in the report's `definition.pbir`. A visual that is registered but not placed on a page can still appear; the `Used in Report` flag from semantic-link-labs is honoured when present.
- Requires the executing identity to have at least **Viewer** role on every workspace it scans.
- semantic-link-labs is an open-source community project maintained by Microsoft engineers — column names may evolve between releases. Pin a version in your custom Fabric environment for production use.
## License
MIT — feel free to adapt and reuse.
https%3A%2F%2Fgithub.com%2FKornAlexander%2FPBI-Tools%2Fblob%2Fmain%2FNotebook%2520Gallery%2FCustom%2520Visuals%2520Inventory.ipynb