diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md
index d1f201360..20780b286 100644
--- a/docs-mslearn/toolkit/changelog.md
+++ b/docs-mslearn/toolkit/changelog.md
@@ -3,7 +3,7 @@ title: FinOps toolkit changelog
description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more.
author: MSBrett
ms.author: brettwil
-ms.date: 10/28/2026
+ms.date: 10/29/2026
ms.topic: reference
ms.service: finops
ms.subservice: finops-toolkit
@@ -18,14 +18,16 @@ ms.reviewer: brettwil
This article summarizes the features and enhancements in each release of the FinOps toolkit.
-
+### [Power BI reports](power-bi/reports.md)
+
+- **Fixed**
+ - Fixed the storage-based Costs query failing to refresh with `Type mismatch (DISP_E_TYPEMISMATCH 0x80020005)` when a row's SKU details didn't carry a clean numeric VCPUs/vCores value (for example, a Marketplace or reservation purchase row). The value is now coerced to null instead of throwing ([#2297](https://github.com/microsoft/finops-toolkit/issues/2297)).
diff --git a/src/power-bi/queries/ftk_NormalizeSchema.pq b/src/power-bi/queries/ftk_NormalizeSchema.pq
index fcd5b6eb5..960eb6409 100644
--- a/src/power-bi/queries/ftk_NormalizeSchema.pq
+++ b/src/power-bi/queries/ftk_NormalizeSchema.pq
@@ -257,7 +257,7 @@ let
{"UsageType", "ImageType", "ServiceType", "VMName", "VMProperties", "VCPUs", "ReservationOrderId", "ReservationId", "VMCapacityReservationId", "AHB", "vCores"},
{"x_SkuUsageType", "x_SkuImageType", "x_SkuType", "tmp_VMName", "x_SkuVMProperties", "tmp_VMvCPUs", "tmp_AddlReservationOrderId", "tmp_AddlReservationId", "x_CapacityCommitmentId", "tmp_SQLAHB", "tmp_SQLvCores"}
),
- SkuDetailsDict_types = Table.TransformColumnTypes(SkuDetailsDict_expand, {{"tmp_VMvCPUs", Int64.Type}, {"tmp_SQLvCores", Int64.Type}}),
+ SkuDetailsDict_types = Table.TransformColumns(SkuDetailsDict_expand, {{"tmp_VMvCPUs", each try Int64.From(_) otherwise null, Int64.Type}, {"tmp_SQLvCores", each try Int64.From(_) otherwise null, Int64.Type}}),
// Columns from AddlInfo -- Must be after ResourceName cleanup
ResourceMachineName = _swapCol(SkuDetailsDict_types, "tmp_VMName", "x_ResourceMachineName", each if _isNotBlank([tmp_VMName]) then [tmp_VMName] else null),
diff --git a/src/power-bi/storage/Shared.Dataset/definition/tables/Costs.tmdl b/src/power-bi/storage/Shared.Dataset/definition/tables/Costs.tmdl
index dd1eccee1..54d939b4d 100644
--- a/src/power-bi/storage/Shared.Dataset/definition/tables/Costs.tmdl
+++ b/src/power-bi/storage/Shared.Dataset/definition/tables/Costs.tmdl
@@ -1685,14 +1685,15 @@ table Costs
// TODO: Add SQL AHB handling
AHB = Table.RemoveColumns(
Table.AddColumn(Table.AddColumn(Table.AddColumn(Table.AddColumn(Table.AddColumn(Table.AddColumn(Table.AddColumn(
- Table.TransformColumnTypes(
+ Table.TransformColumns(
Table.ExpandRecordColumn(
Table.ReplaceErrorValues(Table.AddColumn(ExtractedTags, "tmp_SkuDetails", each [x_SkuDetailsDictionary]), {{"tmp_SkuDetails", null}}),
"tmp_SkuDetails",
{"UsageType", "ImageType", "ServiceType", "VMName", "VMProperties", "VCPUs", "ReservationOrderId", "ReservationId", "VMCapacityReservationId", "AHB", "vCores", "ServerSku"},
{"x_SkuUsageType", "x_SkuImageType", "x_SkuType", "tmp_VMName", "x_SkuVMProperties", "tmp_VMvCPUs", "tmp_AddlReservationOrderId", "tmp_AddlReservationId", "x_CapacityCommitmentId", "tmp_SQLAHB", "tmp_SQLvCores", "tmp_ServerSku"}
),
- {{"tmp_VMvCPUs", Int64.Type}, {"tmp_SQLvCores", Int64.Type}}
+ // Coerce to Int64, degrading any non-numeric/unexpected VCPUs/vCores value (e.g. on a Marketplace, reservation, or other non-VM/SQL charge row) to null instead of throwing a type mismatch error on refresh
+ {{"tmp_VMvCPUs", each try Int64.From(_) otherwise null, Int64.Type}, {"tmp_SQLvCores", each try Int64.From(_) otherwise null, Int64.Type}}
),
"x_ResourceMachineName", each if _isNotBlank([tmp_VMName]) then [tmp_VMName] else null),
"x_SkuCoreCount", each if [tmp_VMvCPUs] <> null then [tmp_VMvCPUs] else if [tmp_SQLvCores] <> null then [tmp_SQLvCores] else null, Int64.Type),
diff --git a/src/powershell/Tests/Lint/PowerQuerySkuDetailsCasts.Tests.ps1 b/src/powershell/Tests/Lint/PowerQuerySkuDetailsCasts.Tests.ps1
new file mode 100644
index 000000000..3c9d20536
--- /dev/null
+++ b/src/powershell/Tests/Lint/PowerQuerySkuDetailsCasts.Tests.ps1
@@ -0,0 +1,40 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+<#
+ Lint rule: values extracted from the free-form x_SkuDetails JSON blob must not be cast with an unguarded
+ Table.TransformColumnTypes.
+
+ The blob's shape varies by charge type (VM usage, Marketplace, reservation purchase, etc.), so VCPUs / vCores
+ are not always a clean number. An unguarded cast produces an Error cell, which Power BI surfaces on refresh as
+ "OLE DB or ODBC error: Type mismatch (DISP_E_TYPEMISMATCH 0x80020005)" (issue #2297). Nothing in CI runs
+ Power Query, so this static check keeps the guarded form (try ... otherwise null) from regressing.
+#>
+
+Describe 'PowerQuerySkuDetailsCasts' {
+
+ BeforeDiscovery {
+ $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path
+ $files = @(
+ Get-ChildItem -Path (Join-Path $repoRoot 'src/power-bi') -Include '*.tmdl', '*.pq' -Recurse -File -ErrorAction SilentlyContinue |
+ Where-Object { (Get-Content -Path $_.FullName -Raw) -match 'tmp_VMvCPUs|tmp_SQLvCores' } |
+ ForEach-Object { @{ FullName = $_.FullName; RelPath = $_.FullName.Substring($repoRoot.Length + 1).Replace('\', '/') } }
+ )
+ }
+
+ It 'Finds at least one dataset that extracts VCPUs/vCores' {
+ $files.Count | Should -BeGreaterThan 0
+ }
+
+ It 'Does not cast tmp_VMvCPUs / tmp_SQLvCores with an unguarded Table.TransformColumnTypes in ' -ForEach $files {
+ $text = Get-Content -Path $FullName -Raw
+ $unguarded = [regex]::Matches($text, '\{\s*"tmp_(VMvCPUs|SQLvCores)"\s*,\s*Int64\.Type\s*\}')
+ $unguarded.Count | Should -Be 0 -Because 'an unparseable VCPUs/vCores value must degrade to null (try ... otherwise null), not fail the refresh'
+ }
+
+ It 'Guards the tmp_VMvCPUs / tmp_SQLvCores cast with try ... otherwise null in ' -ForEach $files {
+ $text = Get-Content -Path $FullName -Raw
+ $text | Should -Match '"tmp_VMvCPUs",\s*each try [^,]+ otherwise null'
+ $text | Should -Match '"tmp_SQLvCores",\s*each try [^,]+ otherwise null'
+ }
+}