Governance AWS cost
untagged resources make cost attribution impossible
Somebody asks what a customer costs to serve, or which team is responsible for the biggest line on the bill, and there is no way to answer. Cost Explorer can group by tag — but only tags that were applied to resources and activated in the billing console, and only from the point of activation onward. Miss either step and the question stays unanswerable no matter how much data accumulates.
Cost allocation needs two things, and people usually do only the first. Tag the resources, then activate the tag keys in Billing → Cost allocation tags. An activated key only applies to costs incurred afterwards, so this is not retroactive.
The Resource Groups Tagging API can list every resource missing a required key, and tag them in bulk. Activation itself is a billing-console action with a lag: up to 24 hours for a key to appear, and up to another 24 to activate.
The problem in plain words
The bill is a single number per service. Without tags, "what does this customer cost" and "which team owns this spend" have no answer, and the usual substitute — guessing from resource names — falls apart the moment naming conventions diverge, which they always do.
The trap that wastes the most time is the second step. Teams tag diligently, wait a month, open Cost Explorer, and find the tag is not available as a grouping dimension. The tags are on the resources; they were never activated for billing, and the month that has passed cannot be recovered.
Why it happens
Tagging and activating are separate systems. One is a resource-level API, the other a billing-console setting. Nothing connects them, and nothing warns that tagging alone achieves nothing for cost reporting.
Activation is not retroactive. Cost data before activation carries no tag dimension and never will. The longer the gap between tagging and activating, the more history is permanently unattributable.
Resources are created faster than they are tagged. Console clicks, Terraform without default tags, an SDK call in a script — each creates something untagged unless someone remembered. Compliance decays without enforcement.
How to fix it
Decide the small set of keys that matter
Three or four is plenty: an owner, an environment, a cost centre or customer, and perhaps a service. A long list guarantees inconsistent application, and inconsistent tags are as useless as none.
Find what is missing them
The Resource Groups Tagging API covers most taggable resources in one call, which is far better than walking every service API.
aws resourcegroupstaggingapi get-resources \
--query 'ResourceTagMappingList[?length(Tags)==`0`].ResourceARN'
Tag in bulk, then activate — and do not skip the second half
tag-resources takes up to 20 ARNs at a time. Once tagged, go to Billing → Cost allocation tags, select the keys and activate them. Nothing in the API does this for you, and until it is done Cost Explorer cannot group by them.
Enforce it so it does not decay
Terraform default_tags at the provider level tags everything it creates. An AWS Config rule or a Service Control Policy can flag or block untagged resources. Without enforcement, compliance drifts back down within a quarter.
How to check it worked
Check the untagged count fell, then confirm the key is actually usable for billing:
aws resourcegroupstaggingapi get-resources \
--query 'length(ResourceTagMappingList[?length(Tags)==`0`])'
Then open Cost Explorer and try grouping by the tag. If it is not offered, activation has not completed — it can take 24 hours to appear and another 24 to activate.
The full code
The script reports tag coverage across every taggable resource, broken down by required key and by service so you can see where the gaps cluster. Bulk tagging takes an explicit key, value and --apply. It cannot activate tags for billing — that is console-only — so it ends by telling you to go and do it.
"""Report cost-allocation tag coverage and bulk-tag what is missing.
Two steps are needed and people usually do only the first: tag the resources, then
ACTIVATE the tag keys in Billing -> Cost allocation tags. Activation is not
retroactive, so every day between tagging and activating is a day of spend that can
never be attributed.
"""
import argparse
import logging
import sys
import boto3
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("aws_tag_coverage")
DEFAULT_REQUIRED = ["Owner", "Environment", "CostCentre"]
def coverage(resources, required):
"""Pure decision function. Returns per-key counts and the untagged ARNs.
Counts a key as present only when it has a non-empty value: an empty string is
worse than no tag, because it looks compliant in a report and groups into a
blank bucket in Cost Explorer.
"""
missing = {k: [] for k in required}
fully_untagged = []
for r in resources:
tags = {t["Key"]: t.get("Value", "") for t in r.get("Tags", [])}
if not tags:
fully_untagged.append(r["ResourceARN"])
for key in required:
if not tags.get(key, "").strip():
missing[key].append(r["ResourceARN"])
return {
"total": len(resources),
"fully_untagged": fully_untagged,
"missing_by_key": missing,
}
def service_of(arn):
parts = arn.split(":")
return parts[2] if len(parts) > 2 else "unknown"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--region", default="us-east-1")
ap.add_argument("--required", nargs="+", default=DEFAULT_REQUIRED)
ap.add_argument("--tag-key")
ap.add_argument("--tag-value")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
api = boto3.client("resourcegroupstaggingapi", region_name=args.region)
resources = []
for page in api.get_paginator("get_resources").paginate():
resources += page["ResourceTagMappingList"]
c = coverage(resources, args.required)
log.info("%d taggable resource(s) in %s", c["total"], args.region)
for key, arns in c["missing_by_key"].items():
pct = 100 * (1 - len(arns) / c["total"]) if c["total"] else 100
log.warning(" %-14s %5.1f%% covered, %d missing", key, pct, len(arns))
if c["fully_untagged"]:
by_service = {}
for arn in c["fully_untagged"]:
by_service[service_of(arn)] = by_service.get(service_of(arn), 0) + 1
log.warning(" %d resource(s) have no tags at all: %s",
len(c["fully_untagged"]),
", ".join(f"{k}={v}" for k, v in sorted(
by_service.items(), key=lambda x: -x[1])[:6]))
if args.tag_key and args.tag_value:
targets = c["missing_by_key"].get(args.tag_key, [])
if args.apply:
for i in range(0, len(targets), 20): # the API caps at 20 ARNs
api.tag_resources(ResourceARNList=targets[i:i + 20],
Tags={args.tag_key: args.tag_value})
log.info("tagged %d resource(s) with %s=%s",
len(targets), args.tag_key, args.tag_value)
else:
log.info("WOULD tag %d resource(s) with %s=%s -- pass --apply",
len(targets), args.tag_key, args.tag_value)
log.info("REMINDER: tagging alone does nothing for cost reporting. Activate the "
"keys in Billing -> Cost allocation tags. It is not retroactive.")
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report cost-allocation tag coverage and bulk-tag what is missing.
*
* Two steps are needed and people usually do only the first: tag the resources,
* then ACTIVATE the tag keys in Billing -> Cost allocation tags. Activation is not
* retroactive.
*/
import {
ResourceGroupsTaggingAPIClient,
GetResourcesCommand,
TagResourcesCommand,
} from '@aws-sdk/client-resource-groups-tagging-api';
const DEFAULT_REQUIRED = ['Owner', 'Environment', 'CostCentre'];
/**
* Pure decision function. Returns per-key counts and the untagged ARNs.
*
* Counts a key as present only when it has a non-empty value: an empty string is
* worse than no tag, because it looks compliant and groups into a blank bucket.
*/
export function coverage(resources, required) {
const missing = Object.fromEntries(required.map((k) => [k, []]));
const fullyUntagged = [];
for (const r of resources) {
const tags = Object.fromEntries((r.Tags ?? []).map((t) => [t.Key, t.Value ?? '']));
if (!Object.keys(tags).length) fullyUntagged.push(r.ResourceARN);
for (const key of required) {
if (!(tags[key] ?? '').trim()) missing[key].push(r.ResourceARN);
}
}
return { total: resources.length, fullyUntagged, missingByKey: missing };
}
const serviceOf = (arn) => arn.split(':')[2] ?? 'unknown';
async function main() {
const region = process.env.AWS_REGION ?? 'us-east-1';
const apply = process.argv.includes('--apply');
const tagKey = process.argv[process.argv.indexOf('--tag-key') + 1];
const tagValue = process.argv[process.argv.indexOf('--tag-value') + 1];
const api = new ResourceGroupsTaggingAPIClient({ region });
const resources = [];
let PaginationToken;
do {
const page = await api.send(new GetResourcesCommand({ PaginationToken }));
resources.push(...(page.ResourceTagMappingList ?? []));
PaginationToken = page.PaginationToken || undefined;
} while (PaginationToken);
const c = coverage(resources, DEFAULT_REQUIRED);
console.log(`${c.total} taggable resource(s) in ${region}`);
for (const [key, arns] of Object.entries(c.missingByKey)) {
const pct = c.total ? 100 * (1 - arns.length / c.total) : 100;
console.warn(` ${key.padEnd(14)} ${pct.toFixed(1)}% covered, ${arns.length} missing`);
}
if (c.fullyUntagged.length) {
const byService = {};
for (const arn of c.fullyUntagged) byService[serviceOf(arn)] = (byService[serviceOf(arn)] ?? 0) + 1;
console.warn(` ${c.fullyUntagged.length} resource(s) have no tags at all:`,
Object.entries(byService).sort((a, b) => b[1] - a[1]).slice(0, 6));
}
if (process.argv.includes('--tag-key')) {
const targets = c.missingByKey[tagKey] ?? [];
if (apply) {
for (let i = 0; i < targets.length; i += 20) { // the API caps at 20 ARNs
await api.send(new TagResourcesCommand({
ResourceARNList: targets.slice(i, i + 20), Tags: { [tagKey]: tagValue } }));
}
console.log(`tagged ${targets.length} resource(s) with ${tagKey}=${tagValue}`);
} else {
console.log(`WOULD tag ${targets.length} with ${tagKey}=${tagValue} -- pass --apply`);
}
}
console.log('REMINDER: tagging alone does nothing for cost reporting. Activate the '
+ 'keys in Billing -> Cost allocation tags. It is not retroactive.');
}
if (import.meta.url === `file://${process.argv[1]}`) main();
Add a test
The rule worth testing is that an empty tag value does not count as coverage. A blank value is worse than a missing tag: it reads as compliant in a report and groups into an unlabelled bucket in Cost Explorer.
from aws_tag_coverage import coverage
REQUIRED = ["Owner", "Environment"]
def res(arn, **tags):
return {"ResourceARN": arn, "Tags": [{"Key": k, "Value": v} for k, v in tags.items()]}
def test_fully_tagged_resource_is_covered():
c = coverage([res("arn:aws:ec2:::i-1", Owner="team", Environment="prod")], REQUIRED)
assert c["missing_by_key"]["Owner"] == []
assert c["fully_untagged"] == []
def test_an_empty_value_does_not_count_as_covered():
"""A blank value reads as compliant and groups into an unlabelled bucket."""
c = coverage([res("arn:aws:ec2:::i-1", Owner="", Environment="prod")], REQUIRED)
assert len(c["missing_by_key"]["Owner"]) == 1
def test_whitespace_only_is_also_missing():
c = coverage([res("arn:aws:ec2:::i-1", Owner=" ", Environment="prod")], REQUIRED)
assert len(c["missing_by_key"]["Owner"]) == 1
def test_a_resource_with_no_tags_is_counted_once_per_key():
c = coverage([{"ResourceARN": "arn:aws:s3:::bucket", "Tags": []}], REQUIRED)
assert len(c["fully_untagged"]) == 1
assert len(c["missing_by_key"]["Owner"]) == 1
assert len(c["missing_by_key"]["Environment"]) == 1
def test_empty_account_does_not_divide_by_zero():
assert coverage([], REQUIRED)["total"] == 0
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { coverage } from './aws-tag-coverage.mjs';
const REQUIRED = ['Owner', 'Environment'];
const res = (arn, tags) => ({
ResourceARN: arn,
Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
});
test('a fully tagged resource is covered', () => {
const c = coverage([res('arn:aws:ec2:::i-1', { Owner: 'team', Environment: 'prod' })], REQUIRED);
assert.deepEqual(c.missingByKey.Owner, []);
});
test('an empty value does not count as covered', () => {
const c = coverage([res('arn:aws:ec2:::i-1', { Owner: '', Environment: 'prod' })], REQUIRED);
assert.equal(c.missingByKey.Owner.length, 1);
});
test('whitespace only is also missing', () => {
const c = coverage([res('arn:aws:ec2:::i-1', { Owner: ' ', Environment: 'prod' })], REQUIRED);
assert.equal(c.missingByKey.Owner.length, 1);
});
test('a resource with no tags is counted once per key', () => {
const c = coverage([{ ResourceARN: 'arn:aws:s3:::b', Tags: [] }], REQUIRED);
assert.equal(c.fullyUntagged.length, 1);
assert.equal(c.missingByKey.Owner.length, 1);
});
FAQ
I tagged everything but Cost Explorer will not group by my tag. Why?
Tagging and activating are separate steps. The tag key has to be activated in Billing → Cost allocation tags before it becomes a grouping dimension. Nothing in the tagging API does this, and nothing warns you.
Is activation retroactive?
No. An activated key only applies to costs incurred after activation. Spend before that point carries no tag dimension and never will, so the gap between tagging and activating is permanently unattributable.
How long does activation take?
Up to 24 hours for a newly used tag key to appear in the cost allocation tags page, and up to another 24 hours for it to activate after you select it. Two days is normal; assume it is broken only after that.
How many tag keys should I require?
Three or four. An owner, an environment, a cost centre or customer, perhaps a service. Long lists get applied inconsistently, and inconsistent tags are as useless for attribution as none.
How do I stop coverage decaying?
Enforce at creation. Terraform default_tags at the provider level tags everything it creates; an AWS Config rule or a Service Control Policy can flag or block untagged resources. Without enforcement, coverage drifts back down within a quarter.
Related field notes
- CloudWatch log groups keep everything forever
- Unattached EBS volumes bill like attached ones
- An idle NAT Gateway still costs $32 a month
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Organizing and tracking costs using AWS cost allocation tags — AWS Billing
- Activating user-defined cost allocation tags — AWS Billing
- Resource Groups Tagging API reference — AWS docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.