Break down cloud cost by team tag
Problem statement
Finance asks how last month's bill splits across teams. You have a simplified billing export: one line per resource with a service, a cost as a decimal string, and a tags object. It is not any provider's real export format, but real exports have the same problems this one does.
Print a table of cost per team tag, largest first, with each team's share of the total (one decimal place) and its most expensive service, then the grand total.
- Tag values are normalized: trim spaces and lowercase, so
" search "counts assearch. - The tag key itself must be exactly
team. A line with notags, empty tags, a blank value or a differently-cased key (Team) goes into(untagged). That bucket is part of the answer: it is the cost nobody owns. - Negative lines (credits, refunds) are real and must reduce the team's total.
- A line whose
costis not a number is skipped and listed at the end, never guessed. - Use
Decimalfor money and round only when printing.
line_items.json
[ {"resource_id": "i-0aa1", "service": "compute", "cost": "412.50", "tags": {"team": "payments"}}, {"resource_id": "db-orders", "service": "database", "cost": "980.00", "tags": {"team": "payments", "env": "prod"}}, {"resource_id": "i-0bb2", "service": "compute", "cost": "150.25", "tags": {"team": "search"}}, {"resource_id": "bucket-logs","service": "storage", "cost": "75.10", "tags": {}}, {"resource_id": "i-0cc3", "service": "compute", "cost": "310.00", "tags": {"Team": "search"}}, {"resource_id": "nat-1", "service": "network", "cost": "96.40"}, {"resource_id": "es-main", "service": "search", "cost": "640.00", "tags": {"team": " search "}}, {"resource_id": "credit-sep", "service": "credit", "cost": "-50.00", "tags": {"team": "payments"}}, {"resource_id": "i-0dd4", "service": "compute", "cost": "abc", "tags": {"team": "ml"}}]Examples
Example 1
Input: python solution.py line_items.json
Output: TEAM COST SHARE TOP SERVICE
payments 1342.50 51.4% database (980.00)
search 790.25 30.2% search (640.00)
(untagged) 481.50 18.4% compute (310.00)
total 2614.25
skipped 1 line(s) with a bad cost: i-0dd4
Explanation: payments is 412.50 + 980.00 - 50.00. i-0cc3 is tagged Team, so its 310.00 lands in (untagged) together with the bucket and the NAT gateway.
Hints
Approach
- One normalization function.
team_ofhandles missingtags,nulltags, a missing key, a blank value and stray whitespace or casing in the value. Everything that does not resolve to a name goes to(untagged). Putting this logic in one function means the rules are easy to read and to change. - Parse money per line.
Decimal(str(item["cost"]))accepts strings and numbers. A bad value raisesInvalidOperation(orKeyErrorif missing); that line is recorded and skipped, and the report says so at the end. Negative values need no special case: they are just added. - Aggregate twice in one pass.
by_teamfor the totals andby_team_servicefor the per-service breakdown, bothdefaultdict(Decimal). - Report. Sort by
(-cost, team). The share iscost / total * 100, rounded half-up to one decimal only for display, with a guard for a zero total. The top service is the maximum of the inner map (ties broken by name so the output is stable).
n is the number of line items, t the number of teams and s the number of services.
O(n + t log t)Space O(t * s)import jsonimport sysfrom collections import defaultdictfrom decimal import Decimal, InvalidOperation, ROUND_HALF_UP TAG = "team"CENT = Decimal("0.01") def team_of(item): tags = item.get("tags") or {} value = (tags.get(TAG) or "").strip().lower() return value or "(untagged)" def main(path): with open(path, encoding="utf-8") as f: items = json.load(f) by_team = defaultdict(Decimal) by_team_service = defaultdict(lambda: defaultdict(Decimal)) skipped = [] for item in items: try: cost = Decimal(str(item["cost"])) except (KeyError, InvalidOperation): skipped.append(item.get("resource_id", "?")) continue team = team_of(item) by_team[team] += cost by_team_service[team][item.get("service", "?")] += cost total = sum(by_team.values(), Decimal(0)) print(f"{'TEAM':<12}{'COST':>10}{'SHARE':>8} TOP SERVICE") for team, cost in sorted(by_team.items(), key=lambda kv: (-kv[1], kv[0])): share = (cost / total * 100).quantize(Decimal("0.1"), ROUND_HALF_UP) if total else Decimal(0) top, top_cost = max(by_team_service[team].items(), key=lambda kv: (kv[1], kv[0])) print(f"{team:<12}{cost.quantize(CENT):>10}{share:>7}% {top} ({top_cost.quantize(CENT)})") print(f"{'total':<12}{total.quantize(CENT):>10}") if skipped: print(f"skipped {len(skipped)} line(s) with a bad cost: {', '.join(skipped)}") if __name__ == "__main__": main(sys.argv[1] if len(sys.argv) > 1 else "line_items.json")Follow-up questions
- List the top 5 untagged resources by cost so someone can chase their owners.
- Split shared costs (the NAT gateway) across teams in proportion to their compute spend.
- Compare with the previous month and flag teams whose cost grew by more than 20%.
Frequently asked questions
Cost allocation by tag is a core cloud and FinOps task, and cost reports are often the first thing a platform team builds for leadership. It tests careful data handling: money types, normalization, and making the unowned share visible instead of hiding it.
Here they do, but not in general. Each share is rounded on its own, so three teams at 33.33...% each print as 33.3% and sum to 99.9%. If the column must add up exactly, use the largest-remainder method: round every share down, then give the leftover tenths to the lines with the biggest remainders.
For the report, it is tempting. But the billing system treats them as different keys, so the money really is unallocated until the tag is fixed. Keep it in (untagged) and list the offending resources so they get retagged.