FinOps Pipeline With FOCUS 1.4: 13 Steps, 75 Min [2026]

Most multicloud cost reports die in a spreadsheet. One engineer exports a CSV from AWS, another pulls a table from Azure, a third pastes in a BigQuery result, and the next day disappears into reconciling column names. This tutorial replaces that ritual with a repeatable FinOps pipeline. Provider exports in the FOCUS format land in object storage, DuckDB normalizes them, a short list of SQL checks guards the numbers, and a scheduled job publishes showback by team plus Kubernetes namespace allocation from OpenCost.

The spec doing the heavy lifting is FOCUS, the FinOps Open Cost and Usage Specification. Version 1.4 is the newest published release and the FinOps Foundation ratified it on June 4, 2026 (the AWS Builder Center runs a deep dive on FOCUS 1.4 and FinOps X 2026 if you want the AWS view of it). The catch is that providers have not caught up. Reporting from October 2026 puts AWS, Azure and Google Cloud exports at roughly the FOCUS 1.2 level, with Google Cloud’s BigQuery export still described as preview. Your pipeline has to absorb that gap, which is exactly why a thin normalization layer you own beats waiting for the clouds.

You will not need a paid platform for any of this. The native dashboards (see how AWS Cost Explorer, Azure Cost Management and Google Cloud Billing compare) stay useful for drill-downs, but none of them answers the cross-cloud question finance keeps asking. The lab below is about 250 lines of Python and SQL. It runs end to end in under two seconds on synthetic data, then you swap in real exports. I tested every script on Ubuntu with Python 3.12 and DuckDB 2.0.0 on October 24, 2026.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why FOCUS 1.4 Matters for Your FinOps Pipeline in 2026

Before FOCUS, every provider invented its own vocabulary. AWS called a line item one thing, Azure another, and Google Cloud a third, so a query that summed compute across clouds needed three different mappings and a person who remembered why. FOCUS defines a common schema for billing and usage data across AWS, Azure, Google Cloud and other technology sources. It is an open specification, licensed CC BY 4.0, maintained through the FinOps Foundation ecosystem. It is not a product, and it has no plans or prices.

Version 1.4 pushed the spec beyond basic consumption normalization. It added invoice reconciliation, richer commitment details and better billing-period analysis, the areas finance teams lean on most. The table below lists what the FinOps Foundation says the release contains.

What FOCUS 1.4 addedCount or nameWhy a pipeline cares
Ratification dateJune 4, 2026Newest published release as of October 24, 2026
New datasets2 (Invoice Detail, Billing Period)Tie consumption rows to issued invoices and provider billing periods
New columns47More commitment and contract fields to preserve in your tables
New attributes6Extra metadata to carry through normalization
Glossary entries17Shared definitions, so a team argues about numbers instead of words
Supported features2, including invoice-reconciliation dataLets you prove that your totals match the bill

One caution keeps the rest of this guide honest: a FinOps Foundation GitHub issue dated October 13, 2026 says the community FOCUS Converters project carries stale AWS, Azure and GCP mappings and does not support current spec versions. That repository is deprioritized partly because the providers began shipping native FOCUS data. The practical reading: native exports are the right input, converters are not, and you should expect to own a small mapping layer yourself.

The urgency also comes from the budget side. Finance teams are pushing FinOps programs harder, and the pressure is visible in coverage of how CFOs are taming runaway cloud costs. The FinOps Foundation’s own write-up of its FinOps X 2026 Day 2 keynote, titled From Alerts to Agents, gives a sense of where the community’s attention sits this year. Dashboards that only alert are giving way to pipelines that feed automation, and a clean unified table is the prerequisite for both.

What You Will Build and What You Need

The finished project has five stages. Exports from each cloud arrive in object storage. DuckDB reads them directly and builds one focus_unified table in USD. A validation script gates the data. An analysis script produces provider, category, team, anomaly and Kubernetes views. A report script writes HTML and Parquet, and cron runs the chain nightly. Here is the target layout.

focus-lab/
  gen_sample_data.py    # Step 2: synthetic exports (skip once you have real ones)
  fx.csv                # Step 7: currency rates, owned by finance
  build.py              # Step 7: normalize into focus_unified
  checks.py             # Step 8: validation gate, non-zero exit on failure
  analysis.py           # Steps 9-12: showback, anomalies, Kubernetes allocation
  report.py             # Step 13: HTML and Parquet outputs
  data/                 # raw exports (synthetic or downloaded)
  out/                  # report.html, focus_unified.parquet

Plan on about 75 minutes if you also wire up one real cloud account. The sample-data path alone takes 15 minutes. The table lists the versions I tested and why each tool is there.

ToolVersion testedRole in the pipeline
Python3.12.3Runs the build, check and report scripts
DuckDB2.0.0 LTS (released October 21, 2026)SQL engine that reads exports and builds the unified table
pandas3.1.0Gives DuckDB results a .df() view and writes HTML tables
AWS CLICurrent v2Lists Data Exports tables and manages the S3 bucket
kubectlMatches your clusterPort-forwards the OpenCost API
HelmCurrent releaseInstalls the OpenCost chart
Kubernetes clusterAny conformant clusterSource of OpenCost allocation data
Cloud permissionsBilling read plus storage readLets DuckDB read S3, Blob Storage and GCS objects

A note on the DuckDB version. The 2.0 line is a long-term support release supported until at least October 2027, and DuckDB 1.4 stays supported only until November 17, 2026. The project also extended 1.5 support through March 2027, so staying on 1.5 is defensible if you cannot upgrade this week. New work should start on 2.0. Only people compiling from source need a C++17 compiler, because 2.0 raised that floor.

Steps 1 and 2: Create the Workspace and Generate Sample Exports

Step 1. Create an isolated Python environment and install the two libraries. A virtual environment keeps the DuckDB version pinned to what you tested, which matters because the project now maintains three supported release lines.

mkdir focus-lab && cd focus-lab
python3 -m venv .venv
./.venv/bin/pip install duckdb pandas
./.venv/bin/python -c "import duckdb; print(duckdb.__version__)"

The last command should print 2.0.0 or a newer 2.x patch release. If it prints a 1.x version, an older wheel got cached, so rerun the install with --upgrade --no-cache-dir.

Step 2. Generate synthetic exports so you can build and test everything before touching a real billing account. The generator writes one CSV per provider using real FOCUS column names, then plants six realistic problems that your pipeline must survive. Azure costs arrive in EUR. Azure tag keys use TitleCase while the others use lowercase. AWS rows include a Savings Plan with covered usage billed at zero. A monthly Purchase row and a Credit row test the charge categories. About 15% of rows carry no team tag. And one Bedrock day spikes to six times normal on September 14, 2026, so Step 11 has something to find. The provider names, services and dollar amounts are invented for the lab.

"""Generate synthetic FOCUS-style exports for three providers plus an OpenCost allocation sample."""
import csv, json, random, pathlib, datetime as dt

random.seed(42)
OUT = pathlib.Path("data")
OUT.mkdir(exist_ok=True)
START, DAYS = dt.date(2026, 7, 1), 115  # July 1 through October 23, 2026
TEAMS = ["checkout", "search", "ml-platform", "data", "platform"]

# (service, FOCUS ServiceCategory, ServiceSubcategory, daily base cost in USD)
CATALOG = {
    "aws": [
        ("Amazon Elastic Compute Cloud", "Compute", "Virtual Machines", 420),
        ("Amazon Elastic Kubernetes Service", "Compute", "Containers", 4.8),
        ("Amazon Simple Storage Service", "Storage", "Object Storage", 35),
        ("Amazon Relational Database Service", "Databases", "Relational Databases", 160),
        ("Amazon Bedrock", "AI and Machine Learning", "Generative AI", 90),
        ("AWS Data Transfer", "Networking", "Network Infrastructure", 28),
    ],
    "azure": [
        ("Virtual Machines", "Compute", "Virtual Machines", 210),
        ("Azure Kubernetes Service", "Compute", "Containers", 2.4),
        ("Storage Accounts", "Storage", "Object Storage", 20),
        ("Azure SQL Database", "Databases", "Relational Databases", 75),
        ("Azure AI Services", "AI and Machine Learning", "Generative AI", 55),
        ("Bandwidth", "Networking", "Network Infrastructure", 12),
    ],
    "gcp": [
        ("Compute Engine", "Compute", "Virtual Machines", 150),
        ("Google Kubernetes Engine", "Compute", "Containers", 2.4),
        ("Cloud Storage", "Storage", "Object Storage", 18),
        ("Cloud SQL", "Databases", "Relational Databases", 60),
        ("Vertex AI", "AI and Machine Learning", "Generative AI", 70),
        ("Networking", "Networking", "Network Infrastructure", 9),
    ],
}
PROVIDERS = {
    "aws": dict(name="Amazon Web Services", acct="111122223333", cur="USD", fx=1.0, region=("us-east-1", "US East (N. Virginia)")),
    "azure": dict(name="Microsoft", acct="a1b2c3d4-0000-4000-8000-00000000cafe", cur="EUR", fx=1 / 1.10, region=("westeurope", "West Europe")),
    "gcp": dict(name="Google Cloud", acct="01A2B3-C4D5E6-F7A8B9", cur="USD", fx=1.0, region=("us-central1", "us-central1")),
}
FIELDS = ["BillingAccountId", "BillingCurrency", "BillingPeriodStart", "BillingPeriodEnd", "ChargeCategory",
          "ChargeDescription", "ChargePeriodStart", "ChargePeriodEnd", "CommitmentDiscountId", "ServiceName",
          "ServiceCategory", "ServiceSubcategory", "RegionId", "RegionName", "ResourceId", "SkuId", "SubAccountId",
          "Tags", "ListCost", "ContractedCost", "BilledCost", "EffectiveCost", "ConsumedQuantity", "ConsumedUnit"]


def tags(provider, team, env, cluster):
    d = {"team": team, "env": env}
    if cluster:
        d["cluster"] = cluster
    if team is None:
        d.pop("team")
    if provider == "azure":  # Azure exports in this lab use TitleCase tag keys
        d = {k.capitalize(): v for k, v in d.items()}
    return json.dumps(d)


for prov, meta in PROVIDERS.items():
    rows = []
    for i in range(DAYS):
        day = START + dt.timedelta(days=i)
        nxt = day + dt.timedelta(days=1)
        month_start = day.replace(day=1)
        month_end = (month_start + dt.timedelta(days=32)).replace(day=1)
        growth = 1 + i * 0.002
        for svc, cat, sub, base in CATALOG[prov]:
            for r in range(3):
                team = random.choice(TEAMS) if random.random() > 0.15 else None
                env = random.choices(["prod", "staging", "dev"], [0.7, 0.2, 0.1])[0]
                cluster = "prod-eks" if prov == "aws" and cat == "Compute" and env == "prod" else None
                factor = growth * random.uniform(0.85, 1.15) / 3
                if prov == "aws" and svc == "Amazon Bedrock" and day == dt.date(2026, 9, 14):
                    factor *= 6  # planted anomaly for Step 10
                lst = round(base * factor, 4)
                contracted, billed, eff = lst * 0.95, lst * 0.95, lst * 0.95
                commit = ""
                if prov == "aws" and svc == "Amazon Elastic Compute Cloud" and env == "prod":
                    commit, billed, eff = "sp-0042", 0.0, lst * 0.72  # Savings Plan covered
                fx = meta["fx"]
                rows.append([meta["acct"], meta["cur"], month_start, month_end, "Usage", f"{svc} usage",
                             f"{day}T00:00:00Z", f"{nxt}T00:00:00Z", commit, svc, cat, sub, meta["region"][0],
                             meta["region"][1], f"{prov}-{svc[:6].lower().replace(' ', '')}-{r}", f"sku-{r}",
                             f"{prov}-sub-{r % 2}", tags(prov, team, env, cluster), round(lst * fx, 4),
                             round(contracted * fx, 4), round(billed * fx, 4), round(eff * fx, 4),
                             round(lst * 10, 2), "Hours"])
        if prov == "aws" and day.day == 1:  # monthly commitment purchase and a credit
            rows.append([meta["acct"], "USD", month_start, month_end, "Purchase", "Savings Plan monthly fee",
                         f"{day}T00:00:00Z", f"{nxt}T00:00:00Z", "sp-0042", "Amazon Elastic Compute Cloud",
                         "Compute", "Virtual Machines", "us-east-1", "US East (N. Virginia)", "sp-0042", "sku-sp",
                         "aws-sub-0", json.dumps({"team": "platform", "env": "prod"}), 0, 0, 3000, 0, 1, "Months"])
            rows.append([meta["acct"], "USD", month_start, month_end, "Credit", "Promotional credit",
                         f"{day}T00:00:00Z", f"{nxt}T00:00:00Z", "", "Amazon Bedrock", "AI and Machine Learning",
                         "Generative AI", "us-east-1", "US East (N. Virginia)", "credit-1", "sku-credit", "aws-sub-0",
                         "{}", 0, 0, -250, -250, 1, "Credits"])
    with open(OUT / f"{prov}_focus.csv", "w", newline="") as f:
        w = csv.writer(f)
        w.writerow(FIELDS)
        w.writerows(rows)
    print(f"{prov}: {len(rows)} rows")

# OpenCost-style allocation response (7-day window, aggregate=namespace)
ns = {"checkout": 410.0, "search": 290.0, "ml-platform": 640.0, "kube-system": 95.0, "monitoring": 70.0, "__idle__": 330.0}
alloc = {}
for name, total in ns.items():
    cpu, ram = round(total * 0.55, 2), round(total * 0.35, 2)
    alloc[name] = {"name": name, "cpuCost": cpu, "ramCost": ram, "pvCost": round(total * 0.06, 2),
                   "networkCost": round(total * 0.04, 2), "totalCost": total,
                   "cpuEfficiency": round(random.uniform(0.18, 0.55), 2), "ramEfficiency": round(random.uniform(0.3, 0.7), 2)}
json.dump({"code": 200, "data": [alloc]}, open(OUT / "opencost_allocation_7d.json", "w"), indent=2)
print("opencost sample written")

Run it and confirm the row counts.

./.venv/bin/python gen_sample_data.py
aws: 2078 rows
azure: 2070 rows
gcp: 2070 rows
opencost sample written

The data folder now holds aws_focus.csv, azure_focus.csv, gcp_focus.csv and a sample OpenCost response. Open one CSV and look at the header. Those 24 columns are a subset of the full spec, enough to teach every technique in this guide without drowning the examples.

Steps 3 to 5: Switch On FOCUS Exports in Each Cloud

Skip this section on your first pass if you only want the sample-data path. When you are ready for real numbers, enable exports in each cloud and let them accumulate for a day or two. Treat the export format as Parquet where offered, because Parquet keeps types intact and avoids the CSV quoting traps covered in the troubleshooting section.

Step 3: AWS Data Exports

AWS delivers billing exports through Data Exports, and its Cloud Financial Management page lists Data Exports next to Cost Explorer and the Cost and Usage Report. The Data Exports API is served from us-east-1, so point your CLI there. List the available tables first, because the FOCUS table name carries a version suffix that changes over time.

aws bcm-data-exports list-tables --region us-east-1 \
  --query "Tables[].TableName" --output text
aws bcm-data-exports get-table --region us-east-1 --table-name <FOCUS_TABLE_FROM_ABOVE>

Pick the FOCUS table, choose Parquet, and deliver to an S3 bucket in the console wizard, which also prints the bucket policy you must attach. According to AWS’s April 2026 announcement, Data Exports can deliver standard exports, FOCUS among them, to a bucket in another authorized account. That is useful when a central FinOps account owns the lake. Expect AWS-specific extension columns prefixed with x_, since the spec reserves that prefix for provider extras.

Step 4: Azure Cost Management exports

In the Azure portal, open Cost Management, choose Exports, and create a new export. Select the FOCUS-format cost and usage dataset, set the schedule to daily, and point it at a storage account container. Microsoft Q&A material lists budgets, cost details, exports and query APIs as the Cost Management toolset, and Microsoft’s finops-toolkit project is associated with Azure FinOps and FOCUS exports. Reporting from October 2026 describes the Azure schema as a FOCUS 1.2 preview, so check the schema version in the export manifest before you trust a new column.

Step 5: Google Cloud billing export to BigQuery

Google Cloud’s FOCUS-format BigQuery export was still reported as preview-level in October 2026, so enable the standard detailed billing export too and keep a mapping view as a fallback. Once a FOCUS table exists in BigQuery, extract it to Cloud Storage as Parquet, which DuckDB can read directly.

bq extract --destination_format=PARQUET \
  'my-project:billing_export.focus_daily' \
  'gs://my-finops-lake/gcp/focus/part-*.parquet'

Google Cloud’s October 22 announcement adds a wrinkle worth planning for. OpenAI models bought through Google Cloud Marketplace can appear on one unified Google Cloud invoice, drawing down existing commitments. Preserve the commitment and marketplace fields during normalization, and keep Marketplace charges separate from native service usage in your reports. Organization structure matters here too, since billing accounts, subscriptions and projects map differently across clouds, as the comparison of landing zone and organization structure differences explains.

Steps 6 and 7: Connect DuckDB and Build the Unified Table

Step 6. DuckDB reads objects straight from cloud storage, so you never load billing data into a separate warehouse. The 2.0 release adds asynchronous I/O designed to speed up parallel remote reads, including s3:// paths, which is the exact access pattern a folder of daily export files creates. Create one secret per cloud, using credential chains so no keys land in your scripts.

INSTALL httpfs; LOAD httpfs;
CREATE SECRET aws_s3 (TYPE s3, PROVIDER credential_chain, REGION 'us-east-1');
SELECT count(*) FROM read_parquet('s3://my-finops-lake/aws/focus/**/*.parquet');

INSTALL azure; LOAD azure;
CREATE SECRET az_blob (TYPE azure, PROVIDER credential_chain, ACCOUNT_NAME 'myfinopsstorage');
SELECT count(*) FROM read_parquet('az://exports/azure/focus/**/*.parquet');

CREATE SECRET gcs_lake (TYPE gcs, KEY_ID '<hmac-key-id>', SECRET '<hmac-secret>');
SELECT count(*) FROM read_parquet('gcs://my-finops-lake/gcp/focus/*.parquet');

Local-first analysis is also a mainstream habit. DuckDB’s own 2024 user survey analysis found that 87% of surveyed users ran DuckDB on laptops, 73% used it through Python and 79% used Parquet, which is precisely this stack. Syntax for extensions can shift between major versions, so confirm the secret options against the 2.0 documentation if a statement errors.

Real exports arrive as many files, often nested under date folders. A glob such as **/*.parquet reads them all, and the filename = true option adds a column holding each row’s source file, which makes duplicate-export debugging far easier. When folders follow a key=value pattern such as billing_period=2026-10, add hive_partitioning = true so DuckDB exposes the folder values as columns and skips folders a query does not need. I confirmed filename = true against the lab CSVs, where it returned all three provider files.

Step 7. Now the core of the pipeline. build.py reads the three exports, converts every amount to USD from a rate table that finance owns, lowercases and unifies tag keys, and stacks the result into focus_unified. Create the rate file first. The values here are placeholders for the lab, so replace them with the rate your finance team uses.

currency,usd_rate
USD,1.0
EUR,1.10

Save that as fx.csv, then create build.py.

import duckdb

con = duckdb.connect("finops.duckdb")
con.execute("CREATE OR REPLACE TABLE fx AS SELECT * FROM read_csv('fx.csv')")

SELECT = """
SELECT
  '{prov}' AS source,
  '{provider_name}' AS ServiceProviderName,
  BillingAccountId, BillingCurrency, SubAccountId,
  CAST(ChargePeriodStart AS TIMESTAMP) AS ChargePeriodStart,
  CAST(BillingPeriodStart AS DATE) AS BillingPeriodStart,
  ChargeCategory, ChargeDescription, CommitmentDiscountId,
  ServiceName, ServiceCategory, ServiceSubcategory, RegionName, ResourceId,
  COALESCE(lower(json_extract_string(Tags, '$.team')), lower(json_extract_string(Tags, '$.Team'))) AS team,
  COALESCE(lower(json_extract_string(Tags, '$.env')),  lower(json_extract_string(Tags, '$.Env')))  AS env,
  COALESCE(json_extract_string(Tags, '$.cluster'), json_extract_string(Tags, '$.Cluster')) AS cluster,
  ListCost * usd_rate AS ListCostUsd,
  BilledCost * usd_rate AS BilledCostUsd,
  EffectiveCost * usd_rate AS EffectiveCostUsd
FROM read_csv('data/{prov}_focus.csv') e
LEFT JOIN fx ON fx.currency = e.BillingCurrency
"""

parts = [
    SELECT.format(prov="aws", provider_name="Amazon Web Services"),
    SELECT.format(prov="azure", provider_name="Microsoft"),
    SELECT.format(prov="gcp", provider_name="Google Cloud"),
]
con.execute("CREATE OR REPLACE TABLE focus_unified AS " + " UNION ALL ".join(parts))
print(con.execute("""
  SELECT source, count(*) AS rows, round(sum(BilledCostUsd),2) AS billed_usd, round(sum(EffectiveCostUsd),2) AS effective_usd
  FROM focus_unified GROUP BY source ORDER BY source
""").df().to_string(index=False))

Four decisions in that file deserve a defense. The join to the rate table is a LEFT JOIN, so a currency with no rate produces a null cost that Step 8 will catch, instead of a silently dropped row. Tag extraction tries both $.team and $.Team because Azure disagrees with the other two on case. The provider name is set by the script as ServiceProviderName, the FOCUS 1.2-and-later column, because older exports call it ProviderName. And every cost column gets a Usd suffix so nobody mistakes a converted number for the original currency. Run it.

./.venv/bin/python build.py
source  rows  billed_usd  effective_usd
   aws  2078    66281.86       80926.22
 azure  2070    45796.56       45796.56
   gcp  2070    37671.05       37671.05

Azure’s two cost totals match, while AWS shows a gap between billed and effective cost. That gap is the Savings Plan at work, and Step 9 explains why it matters.

Step 8: Validate Before You Trust a Dollar

A pipeline that publishes wrong numbers is worse than no pipeline, because people act on them. The validation script runs five assertions against focus_unified and exits non-zero if any fail. Wire it before the report in cron, and a bad export never reaches a dashboard.

import duckdb, sys

con = duckdb.connect("finops.duckdb", read_only=True)
checks = {
    "no null charge periods": "SELECT count(*) FROM focus_unified WHERE ChargePeriodStart IS NULL",
    "no negative usage rows": "SELECT count(*) FROM focus_unified WHERE ChargeCategory = 'Usage' AND BilledCostUsd < 0",
    "no duplicate usage rows": """SELECT count(*) FROM (
        SELECT ResourceId, ChargePeriodStart, ChargeDescription, count(*) c
        FROM focus_unified WHERE ChargeCategory = 'Usage' GROUP BY ALL HAVING c > 1)""",
    "all currencies converted": "SELECT count(*) FROM focus_unified WHERE BilledCostUsd IS NULL",
    "known categories only": """SELECT count(*) FROM focus_unified
        WHERE ChargeCategory NOT IN ('Usage','Purchase','Tax','Credit','Adjustment')""",
}
failed = 0
for name, sql in checks.items():
    bad = con.execute(sql).fetchone()[0]
    print(f"{'PASS' if bad == 0 else 'FAIL'}  {name}  (bad rows: {bad})")
    failed += bad > 0
tagless = con.execute("""SELECT round(100.0 * sum(EffectiveCostUsd) FILTER (WHERE team IS NULL) / sum(EffectiveCostUsd), 1)
                         FROM focus_unified WHERE ChargeCategory = 'Usage'""").fetchone()[0]
print(f"INFO  untagged spend share: {tagless}%")
sys.exit(1 if failed else 0)
PASS  no null charge periods  (bad rows: 0)
PASS  no negative usage rows  (bad rows: 0)
PASS  no duplicate usage rows  (bad rows: 0)
PASS  all currencies converted  (bad rows: 0)
PASS  known categories only  (bad rows: 0)
INFO  untagged spend share: 15.0%

The final line is informational. It reports that 15.0% of usage spend carries no team tag, a number you should track weekly because it measures how much of your showback is guesswork. To prove the gate works, I deleted the EUR row from fx.csv and reran the build. The currency check failed and the script returned exit code 1.

FAIL  all currencies converted  (bad rows: 2070)

Every Azure row lost its conversion, and the pipeline stopped. With an inner join the same mistake would have shrunk the totals quietly and passed every check. Add checks as you meet new failure modes. A row-count floor is the cheapest to write. Each provider produces 18 usage rows per day in the lab, so this query lists any day that falls short, and it returns nothing on healthy data.

SELECT source, CAST(ChargePeriodStart AS DATE) AS day, count(*) AS n
FROM focus_unified WHERE ChargeCategory = 'Usage'
GROUP BY ALL HAVING n < 18 ORDER BY day;

Set the threshold from your own normal volume. Two more good candidates: a freshness check on the newest ChargePeriodStart per provider, and a comparison of your monthly total with the invoice total once you ingest the Invoice Detail dataset from FOCUS 1.4.

Steps 9 and 10: Pick the Right Cost Column, Then Run Showback

Step 9. FOCUS carries four cost columns, and picking the wrong one is the most common error in multicloud reports. The table sets out which to use for what.

ColumnWhat it measuresUse it for
ListCostPublic list price before any discountMeasuring how much your discounts are worth
ContractedCostPrice under your negotiated agreementChecking that contract terms apply correctly
BilledCostWhat lands on the invoiceCash and invoice reconciliation
EffectiveCostAmortized cost, with commitment fees spread across the usage they coverShowback, chargeback and unit economics

The lab data makes the difference visible. When a Savings Plan covers EC2 usage, the usage rows show a billed cost of zero, because the money moves through a separate monthly Purchase row. Summing billed cost by team would blame whoever happened to buy the plan, while effective cost spreads the benefit to the teams that consumed it. Default to effective cost for any report a human will argue about.

Create analysis.py. It holds four report blocks, and this step runs the first three. The same file carries the anomaly query for Step 11 and the Kubernetes join for Step 12.

import duckdb, json

con = duckdb.connect("finops.duckdb")
pd_opts = dict(index=False)

print("\n== Spend by provider and month (EffectiveCost, USD) ==")
print(con.execute("""
  SELECT strftime(BillingPeriodStart, '%Y-%m') AS month,
         round(sum(EffectiveCostUsd) FILTER (WHERE source='aws'), 0)   AS aws,
         round(sum(EffectiveCostUsd) FILTER (WHERE source='azure'), 0) AS azure,
         round(sum(EffectiveCostUsd) FILTER (WHERE source='gcp'), 0)   AS gcp,
         round(sum(EffectiveCostUsd), 0) AS total
  FROM focus_unified GROUP BY 1 ORDER BY 1""").df().to_string(**pd_opts))

print("\n== Spend by ServiceCategory (all providers) ==")
print(con.execute("""
  SELECT ServiceCategory, round(sum(EffectiveCostUsd), 0) AS usd,
         round(100 * sum(EffectiveCostUsd) / sum(sum(EffectiveCostUsd)) OVER (), 1) AS pct
  FROM focus_unified GROUP BY 1 ORDER BY usd DESC""").df().to_string(**pd_opts))

print("\n== Showback by team ==")
print(con.execute("""
  SELECT coalesce(team, '(untagged)') AS team, round(sum(EffectiveCostUsd), 0) AS usd
  FROM focus_unified WHERE ChargeCategory = 'Usage' GROUP BY 1 ORDER BY usd DESC""").df().to_string(**pd_opts))

print("\n== BilledCost vs EffectiveCost, AWS compute ==")
print(con.execute("""
  SELECT ChargeCategory, round(sum(BilledCostUsd), 0) AS billed, round(sum(EffectiveCostUsd), 0) AS effective
  FROM focus_unified WHERE source = 'aws' AND ServiceCategory = 'Compute' GROUP BY 1 ORDER BY 1""").df().to_string(**pd_opts))

print("\n== Daily anomalies (z-score > 3 vs trailing 14 days, per service) ==")
con.execute("""
  CREATE OR REPLACE TABLE daily AS
  SELECT source, ServiceName, CAST(ChargePeriodStart AS DATE) AS day, sum(EffectiveCostUsd) AS usd
  FROM focus_unified WHERE ChargeCategory = 'Usage' GROUP BY ALL""")
print(con.execute("""
  WITH w AS (
    SELECT *, avg(usd) OVER win AS mean14, stddev_samp(usd) OVER win AS sd14
    FROM daily
    WINDOW win AS (PARTITION BY source, ServiceName ORDER BY day ROWS BETWEEN 14 PRECEDING AND 1 PRECEDING))
  SELECT source, ServiceName, day, round(usd, 0) AS usd, round(mean14, 0) AS mean14, round((usd - mean14) / sd14, 1) AS z
  FROM w WHERE sd14 > 0 AND (usd - mean14) / sd14 > 3 ORDER BY z DESC LIMIT 5""").df().to_string(**pd_opts))

print("\n== Kubernetes: allocate prod-eks cloud cost to namespaces (OpenCost 7-day shares) ==")
alloc = json.load(open("data/opencost_allocation_7d.json"))["data"][0]
con.execute("CREATE OR REPLACE TABLE oc_alloc (namespace VARCHAR, total_cost DOUBLE, cpu_eff DOUBLE)")
con.executemany("INSERT INTO oc_alloc VALUES (?, ?, ?)",
                [(k, v["totalCost"], v["cpuEfficiency"]) for k, v in alloc.items()])
print(con.execute("""
  WITH cluster AS (
    SELECT sum(EffectiveCostUsd) AS usd FROM focus_unified
    WHERE cluster = 'prod-eks' AND CAST(ChargePeriodStart AS DATE) >= DATE '2026-10-17')
  SELECT namespace, total_cost AS opencost_usd,
         round(100 * total_cost / sum(total_cost) OVER (), 1) AS share_pct,
         round(100 * total_cost / sum(total_cost) OVER () * (SELECT usd FROM cluster) / 100, 0) AS allocated_cloud_usd,
         cpu_eff
  FROM oc_alloc ORDER BY total_cost DESC""").df().to_string(**pd_opts))

Step 10. Run it and read the first sections.

./.venv/bin/python analysis.py

== Spend by provider and month (EffectiveCost, USD) ==
  month     aws   azure     gcp   total
2026-07 19918.0 11429.0  9471.0 40818.0
2026-08 21307.0 12147.0  9947.0 43400.0
2026-09 22179.0 12312.0 10190.0 44681.0
2026-10 17523.0  9908.0  8064.0 35495.0

== Spend by ServiceCategory (all providers) ==
        ServiceCategory     usd  pct
                Compute 87984.0 53.5
              Databases 35932.0 21.9
AI and Machine Learning 25611.0 15.6
                Storage  8916.0  5.4
             Networking  5952.0  3.6

== Showback by team ==
       team     usd
       data 29834.0
   platform 28993.0
   checkout 28185.0
ml-platform 27964.0
     search 25650.0
 (untagged) 24768.0

== BilledCost vs EffectiveCost, AWS compute ==
ChargeCategory  billed  effective
      Purchase 12000.0        0.0
         Usage 16795.0    43439.0

Compute is the largest slice of spend, and AI and machine learning is a smaller slice that grows fast once teams adopt token-metered services. The showback table has a more awkward lesson. Untagged usage (synthetic data) comes close to the search team’s total. If you present this table without a footnote, someone will ask why nobody owns such a large share of the bill. Fix tag coverage at the source with tag policies and required tags in infrastructure code, and use this query as the scoreboard. If you are standing up native tooling first, the AWS Cost Explorer and Budgets setup guide covers cost allocation tags on the AWS side.

The last block in that output shows the billed-versus-effective gap. Billed usage for AWS compute is far lower than its effective usage, while the Purchase rows add billed cost and zero effective cost. Billed usage alone makes AWS compute look much cheaper than its effective cost, and a per-team report built on it would mislead every reader.

Step 11: Catch Spend Spikes With a Window Function

Budgets alert at a threshold. They do not tell you that a service tripled overnight while still sitting below the monthly cap. A rolling z-score against the trailing 14 days catches that pattern with one window function, and DuckDB runs it across the whole unified table in milliseconds.

The anomaly block in analysis.py first builds a daily table of effective cost per provider, service and day. Then it computes the mean and sample standard deviation over the 14 preceding rows, excluding the current day, and flags days more than three standard deviations above the mean. Here is the output from the lab data.

== Daily anomalies (z-score > 3 vs trailing 14 days, per service) ==
source                  ServiceName        day   usd  mean14     z
   aws               Amazon Bedrock 2026-09-14 542.0    97.0 134.4
   gcp               Compute Engine 2026-09-05 181.0   154.0   4.2
   aws Amazon Elastic Compute Cloud 2026-07-03 348.0   311.0   4.0
   gcp                    Vertex AI 2026-07-27  79.0    68.0   3.9
   gcp                    Cloud SQL 2026-07-03  65.0    57.0   3.8

The planted Bedrock spike in the lab data scores a z far above any threshold, so no reasonable setting misses it. The four rows beneath it are noise, with z scores between 3.8 and 4.2 on days that moved by $37 or less. That is the standard trap with z-scores on stable series. A tiny standard deviation turns small wiggles into huge ratios. Add a dollar floor and raise the threshold, so only moves that matter to a person get flagged.

WHERE sd14 > 0 AND usd - mean14 > 100 AND (usd - mean14) / sd14 > 6

With that filter the lab returns exactly one row, the Bedrock day. AI spend is the category most likely to produce real spikes in 2026, since token-metered services can run away faster than any instance fleet. AWS says it added Amazon Bedrock product attributes to Cost Explorer, Budgets and Cost Management Dashboards on October 8, 2026, so you can group by model, model provider and inference type in the console. The pipeline view complements that, because it puts Bedrock beside Azure and Google Cloud model spend in one table. For context on why the model bill varies so much by cloud, see the GPU price gaps between the three clouds.

Step 12: Add Kubernetes Allocation With OpenCost

Cloud bills stop at the node. Your invoice says a cluster of EC2 instances cost a certain amount, but not which namespace or team used the CPU. OpenCost fills that gap. It is an open-source Kubernetes cost monitoring project and a CNCF Incubating project, accepted in June 2022 and promoted to Incubating on October 25, 2024. It is not a billing normalizer, and the official FOCUS 1.4 material does not show native OpenCost allocation, so the join happens in your pipeline.

Install OpenCost with Helm. It needs a Prometheus to read from. The chart values below point at the Prometheus service created in the first command, and you should confirm the exact value names with helm show values opencost/opencost because chart options evolve.

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add opencost https://opencost.github.io/opencost-helm-chart
helm install prometheus prometheus-community/prometheus -n prometheus-system --create-namespace
helm install opencost opencost/opencost -n opencost --create-namespace \
  --set opencost.prometheus.internal.serviceName=prometheus-server \
  --set opencost.prometheus.internal.namespaceName=prometheus-system

kubectl -n opencost port-forward service/opencost 9003:9003 &
curl -s -G http://localhost:9003/allocation/compute \
  -d window=7d -d aggregate=namespace -d accumulate=true > data/opencost_allocation_7d.json

The last command overwrites the sample file with live data, in the same shape the lab uses. Each namespace carries cpuCost, ramCost, pvCost, networkCost, totalCost and efficiency ratios. The release line moves quickly, so pin a chart version in production rather than tracking latest.

The join in analysis.py takes the cloud cost of everything tagged cluster = prod-eks over the same seven days and splits it by each namespace’s share of OpenCost’s total. Here is the output.

== Kubernetes: allocate prod-eks cloud cost to namespaces (OpenCost 7-day shares) ==
  namespace  opencost_usd  share_pct  allocated_cloud_usd  cpu_eff
ml-platform         640.0       34.9                626.0     0.23
   checkout         410.0       22.3                401.0     0.43
   __idle__         330.0       18.0                323.0     0.31
     search         290.0       15.8                283.0     0.48
kube-system          95.0        5.2                 93.0     0.35
 monitoring          70.0        3.8                 68.0     0.37

Read the __idle__ row first. It is capacity you pay for that no workload requested, and in this sample it is a sizable share of the cluster. The ml-platform namespace takes the largest share of the cost at a low CPU efficiency, which means it requests far more CPU than it uses. That is a rightsizing conversation with one team, and it exists only because the Kubernetes layer and the billing layer now sit in one place. The proportional split is a simplification, so document it for your finance partners and consider weighting by CPU and memory separately later.

OpenCost 1.121.0 also added Kubernetes inference cost tracking through an integration with the CNCF llm-d project, as the CNCF write-up of OpenCost 1.121.0 describes. The OpenCost team’s worked example shows why it matters. A self-hosted model at 25% utilization costs $1.00 per million tokens on a usage basis but $4.00 per million on an allocation basis, against an external API price of $2.00, and self-hosting becomes competitive above roughly 50% utilization. If you serve models on your own clusters, track the allocation-based figure. Cast AI has also published an explainer on the FOCUS spec applied to Kubernetes and AI costs, which is a good companion read.

Step 13: Publish the Report and Schedule the Job

The last stage turns tables into artifacts that people open. report.py writes a small static HTML page and exports the unified table as Parquet, which any BI tool can read later.

import duckdb, pathlib, datetime as dt

con = duckdb.connect("finops.duckdb", read_only=True)
out = pathlib.Path("out"); out.mkdir(exist_ok=True)
sections = {
    "Spend by provider and month": """
        SELECT strftime(BillingPeriodStart, '%Y-%m') AS month, source,
               round(sum(EffectiveCostUsd), 0) AS effective_usd
        FROM focus_unified GROUP BY ALL ORDER BY 1, 2""",
    "Top 10 services": """
        SELECT source, ServiceName, round(sum(EffectiveCostUsd), 0) AS effective_usd
        FROM focus_unified GROUP BY ALL ORDER BY effective_usd DESC LIMIT 10""",
    "Showback by team": """
        SELECT coalesce(team, '(untagged)') AS team, round(sum(EffectiveCostUsd), 0) AS effective_usd
        FROM focus_unified WHERE ChargeCategory = 'Usage' GROUP BY 1 ORDER BY 2 DESC""",
}
html = [f"<h1>Multicloud FinOps report</h1><p>Generated {dt.date.today()} from FOCUS-style exports.</p>"]
for title, sql in sections.items():
    html.append(f"<h2>{title}</h2>" + con.execute(sql).df().to_html(index=False, border=0))
(out / "report.html").write_text("<meta charset='utf-8'><style>body{font:15px sans-serif;margin:2rem}td,th{padding:4px 12px}</style>" + "".join(html))
con.execute("COPY focus_unified TO 'out/focus_unified.parquet' (FORMAT parquet)")
print("wrote out/report.html and out/focus_unified.parquet")
wrote out/report.html and out/focus_unified.parquet

Open out/report.html in a browser to see three tables: spend by provider and month, the ten biggest services, and showback by team. Static HTML is deliberate. It needs no server, it can sit in a private bucket behind your identity provider, and it survives staff turnover better than a dashboard nobody remembers building.

Now schedule the chain. Order matters, so use && so a failed check stops the report from refreshing.

15 6 * * * cd /opt/focus-lab && ./.venv/bin/python build.py && ./.venv/bin/python checks.py && ./.venv/bin/python report.py >> run.log 2>&1

Exports land with a delay, so schedule the job well after your providers’ export windows and confirm in the first week that yesterday’s data is complete. If you would rather not maintain a server, the same three scripts fit a scheduled function, and the serverless runtime limits across the big three tell you whether a nightly build fits in one invocation. Keep finops.duckdb on persistent storage in that case, or rebuild from Parquet every run, which this dataset size easily allows.

Common Pitfalls

Seven mistakes cause most of the bad numbers in homegrown cost pipelines. Each one is cheap to prevent once you know it.

  • Mixing billed and effective cost. Reports that sum BilledCost by team punish whoever bought a commitment and flatter everyone who used it. Use EffectiveCost for showback and keep BilledCost for invoice reconciliation only.
  • Assuming every export is FOCUS 1.4. Providers reportedly ship exports near the 1.2 level. A query that references a 1.4-only column fails on AWS data, so check the manifest version and keep mappings per provider.
  • Ignoring tag key case. Azure’s TitleCase keys mean team and Team are different keys. Normalize with lower() on both keys and values, then count coverage after normalization.
  • Converting currency at the wrong time. Converting after aggregation hides month-boundary effects. Convert each row at the rate finance approves, and store the rate date next to the table.
  • Double counting commitments. Adding Purchase rows to effective cost counts the same money twice. Purchases belong in cash views, amortized usage belongs in showback.
  • Rescanning the whole lake nightly. Reading every file every run works at lab scale and gets slow at year-three scale. Partition by billing month and rebuild only the current and previous month.
  • Skipping the validation gate. A pipeline without checks eventually publishes a wrong total with a confident chart. The gate in Step 8 costs ten lines.

Troubleshooting

These are the failures you are most likely to hit, with the cause and the fix. I reproduced the first three while building the lab.

  • Secret Validation Failure: no profile ‘default’ found in config file. DuckDB’s credential_chain provider found no AWS profile. Run aws configure, export AWS_PROFILE, or run on a host with an instance role, then recreate the secret.
  • Binder Error: Referenced column not found. A column name in the SQL does not match the table. In my first build the rate column was usd_rate while the query used rate. Run DESCRIBE on the source and match names exactly.
  • FAIL all currencies converted with thousands of bad rows. A currency has no rate in fx.csv. Add it and rebuild. The check did its job, so do not loosen it.
  • IO Error about being unable to set a lock on the database file. Another process holds finops.duckdb open for writing, often a notebook. Close it, or open the file with read_only=True for reports.
  • Access denied errors when reading s3:// or az:// paths. The identity lacks list or read permission on the bucket or container. Fix the policy or role, and check that the export wizard’s bucket policy was attached.
  • Conversion Error: invalid timestamp. Providers format timestamps differently. Cast with TRY_CAST(ChargePeriodStart AS TIMESTAMP), count the nulls, and inspect sample rows before choosing a parse format.
  • Tags column breaks JSON functions. CSV quoting damaged the embedded JSON. Re-read with quote='"' and escape='"', or request Parquet exports to avoid it entirely.
  • UNION ALL fails on column count or type. Provider schemas drifted. Select explicit columns as build.py does, or use UNION ALL BY NAME while debugging.
  • Anomaly query returns nothing. The window needs 14 days of history before it can score a day, and a flat series has a standard deviation of zero. Wait for history or shorten the frame.
  • OpenCost returns empty data. Prometheus is not scraping yet, the port-forward died, or the window is longer than the data you hold. Check the OpenCost pod logs, confirm Prometheus targets are up and try window=1d.
  • Export files missing for yesterday. Exports arrive late. Add a row-count floor check per provider per day, and move the cron time later.

Advanced Tips

Once the basics run, six upgrades give the best return.

Partition the Parquet output. Write focus_unified partitioned by billing month, for example COPY focus_unified TO 'out/lake' (FORMAT parquet, PARTITION_BY (source, BillingPeriodStart)). Downstream tools prune partitions, and rebuilds touch only the current month.

Prepare for FOCUS 1.4 columns. Add Invoice Detail and Billing Period datasets to the lake as separate tables when your providers start emitting them. Joining consumption rows to invoice rows is how you prove the pipeline equals the bill, and it is the headline capability of the 1.4 release.

Preserve provider extras. Keep x_ extension columns in a side table, or try DuckDB 2.0’s first-class VARIANT type for semi-structured fields. I have not tested VARIANT for this use, so run it on a copy first.

Track commitments explicitly. Aggregate by CommitmentDiscountId to see utilization of each plan, and compare list cost with effective cost to quantify the discount you earn. The same view shows when interruptible capacity pays off, as in this walkthrough of AWS Spot savings on game server fleets.

Test engine upgrades on a copy. DuckDB 2.0 replaces the PostgreSQL-derived parser with a PEG-based one, and any major release can change edge-case behavior. Before you move a production file from 1.5 to 2.0, rebuild finops.duckdb from the raw exports under both versions and diff the totals with a query. Matching numbers are your upgrade proof, and a mismatch tells you which statement to inspect before users see a different report.

Automate the questions. AWS says it can connect to Billing and Cost Management through a Billing and Cost Management MCP server, and DuckDB 2.0 ships an agent mode. Both point toward asking cost questions in plain language against your own clean table. Treat that as an experiment, keep read-only credentials, and let the validation gate stay in front of any numbers an assistant reports.

Complete Project Layout and How This Stacks Up

You now have the complete working project: gen_sample_data.py for tests, build.py, checks.py, analysis.py and report.py, plus fx.csv. Run order for a fresh environment is generate, build, check, analyze and report. A single shell wrapper that chains them with && is all the orchestration you need at this scale.

./.venv/bin/python gen_sample_data.py && \
./.venv/bin/python build.py && \
./.venv/bin/python checks.py && \
./.venv/bin/python analysis.py && \
./.venv/bin/python report.py

Is the DIY route right for you? It depends on team size and how many clouds you run. The table compares four common approaches on qualities rather than prices, because pricing varies by contract.

ApproachCross-cloud viewKubernetes allocationSetup effortControl over mappings
DuckDB plus FOCUS (this guide)Yes, one tableYes, via OpenCost joinHoursFull
Native cloud consolesNo, one cloud eachLimitedMinutesNone
OpenCost aloneNo, Kubernetes onlyYesAn hourModerate
Commercial FinOps platformYesUsually yesDays, plus procurementVendor decides

The pipeline wins when you have two or more clouds, someone who can maintain 250 lines of SQL and Python, and a need to explain numbers to finance. A commercial platform wins when you want anomaly workflows, budgets and approvals out of the box without engineering time. Some teams run both, using the pipeline as the audit layer. For more on the platforms behind these services, browse the cloud computing coverage on this site.

FAQ

What is FOCUS in FinOps?

FOCUS is the FinOps Open Cost and Usage Specification, an open schema that standardizes billing and usage data across cloud providers. It is maintained through the FinOps Foundation ecosystem and licensed CC BY 4.0. The newest published version is 1.4, ratified on June 4, 2026.

Do AWS, Azure and Google Cloud export FOCUS 1.4 today?

Not fully. Reporting from October 2026 says provider exports sit near the FOCUS 1.2 level, with Google Cloud’s BigQuery export still at preview level. Plan for provider-specific mappings and check each export’s schema version.

Is FOCUS 1.5 released?

No. FOCUS 1.5 is in development with a planned December 2026 announcement. Build against 1.4 until the new version ships.

Why use DuckDB for a FinOps pipeline?

DuckDB runs in-process, reads Parquet and CSV straight from object storage, and handles window functions and JSON natively, so one small script replaces a warehouse for billing-sized data. Version 2.0.0 LTS shipped on October 21, 2026 and is supported until at least October 2027.

Should I use BilledCost or EffectiveCost for showback?

Use EffectiveCost. It amortizes commitment fees across the usage they cover, so teams are charged for what they consumed. Keep BilledCost for reconciling to the invoice.

Does OpenCost replace Kubecost or cloud billing tools?

No. OpenCost is a CNCF Incubating project for Kubernetes cost visibility and allocation, not a billing normalizer. This pipeline pairs it with FOCUS exports so cluster costs and cloud bills meet in one table. Commercial tools built on similar data add workflows on top.

Can I run this on a laptop?

Yes. The lab runs in under two seconds on synthetic data, and a laptop is the usual home for DuckDB. Move to a small scheduled VM or function when you need the nightly refresh.

How do I know the pipeline matches the invoice?

Ingest the Invoice Detail dataset from FOCUS 1.4 once your providers emit it, then add a check that sums billed cost per invoice and compares it to the invoice amount. Until then, compare monthly provider totals with the invoice PDF by hand and note any variance.

Sofia Lindström

Sofia Lindström

Editor-in-Chief

Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles