Find Untagged AWS Resources With the Tagging API

Rows of storage boxes on warehouse shelves, each with a printed label

Photo by Jaime Nugent on Unsplash

To find untagged AWS resources, page through the Resource Groups Tagging API GetResources call in each Region and compare every resource’s tag keys with the keys you require. GetResources only returns resources that have or once had tags, so add an AWS Resource Explorer search for tag:none to catch resources that were never tagged.

Untagged resources are the part of the bill nobody owns. The instance has no Owner, the bucket has no CostCenter, and when you group Cost Explorer by tag they all land in one untagged bucket that grows every month. This example is for engineers and FinOps-minded leads who want to find untagged AWS resources, see exactly which required keys each one is missing, and hand a CSV to the teams that can fix them.

You get a read-only TypeScript script for the AWS SDK for JavaScript v3. It’s one of our AWS SDK v3 cost and cleanup examples, and it’s the step before any cost-by-team report: tags are what let a script like get last month’s AWS cost broken down by service go one level deeper, to owner and project.

Why doesn’t GetResources return untagged resources?

The Tagging API answers “which resources carry which tags”. Its GetResources API reference says so directly: it returns resources that are tagged or were previously tagged, and it does not return untagged resources. Previously tagged resources whose tags were all removed come back with an empty tag list. That gives you two different gaps:

Gap How the script finds it Limit
Tagged, but missing a required key GetResources, 100 resources per page, per Region Only resources that have or had a tag
Never tagged at all Resource Explorer Search with tag:none (optional --explorer) At most 1,000 results per query; needs an index and a view that includes tags

Two more details matter when you read the report. Tag keys that start with aws: are created by AWS (for example by CloudFormation), so the script ignores them when it checks your keys. And Resource Explorer’s tag:none matches resources without user-created tags, so a resource that only carries AWS-created tags still shows up, which is what you want here. Resource Explorer is offered at no additional charge.

What does the script do?

  1. Reads your required keys--required Owner,CostCenter. Tag keys are case-sensitive, so owner doesn’t satisfy Owner. An empty value counts as missing.
  2. Pages through the Tagging API per RegionpaginateGetResources with ResourcesPerPage: 100, for each Region in --regions.
  3. Optionally adds never-tagged resourcesWith --explorer, runs tag:none region:<region> resourcetype.supports:tags through paginateSearch, skipping ARNs it has already reported, and warns when the 1,000-result cap cuts the list short.
  4. Checks cost allocation statusWith --cost-tags, calls Cost Explorer’s ListCostAllocationTags for your required keys and prints whether each one is Active.
  5. Reports and exportsPrints a table and a count by resource type, and writes every row to CSV with --csv. It never adds or changes a tag.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • @aws-sdk/client-resource-groups-tagging-api, @aws-sdk/client-resource-explorer-2 and @aws-sdk/client-cost-explorer.
  • For --explorer: Resource Explorer turned on in the Regions you scan, with a default view that includes tags. Without tags in the view, a tag: query fails with a validation error.
  • For --cost-tags: run it with the management account or a standalone account, because only those can manage cost allocation tags.

Which IAM permissions does it need?

All three actions are read-only. Resource Explorer’s Search is authorized against the view it uses, so that statement names view ARNs; replace 123456789012 with your account ID. Remove the statements for the flags you don’t use.

untagged-resources-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadTagsWithTaggingApi",
      "Effect": "Allow",
      "Action": "tag:GetResources",
      "Resource": "*"
    },
    {
      "Sid": "SearchResourceExplorer",
      "Effect": "Allow",
      "Action": "resource-explorer-2:Search",
      "Resource": "arn:aws:resource-explorer-2:*:123456789012:view/*"
    },
    {
      "Sid": "ReadCostAllocationTagStatus",
      "Effect": "Allow",
      "Action": "ce:ListCostAllocationTags",
      "Resource": "*"
    }
  ]
}

The IAM policy generator for TypeScript AWS SDK code can draft this from the script, and the checklist to review a generated IAM policy for least privilege shows what to tighten afterwards.

The full script to find untagged AWS resources

find-untagged-aws-resources.ts

// find-untagged-aws-resources.ts
// Reports AWS resources that are missing required tag keys, Region by Region.
//  - Resource Groups Tagging API (GetResources): resources that have, or once had, tags.
//  - Optional --explorer: AWS Resource Explorer "tag:none" search for resources that were never tagged.
//  - Optional --cost-tags: whether each required key is active as a cost allocation tag.
// Read-only. Writes a CSV report if --csv is given.
// Usage: npx tsx find-untagged-aws-resources.ts --required Owner,CostCenter
//          [--regions us-east-1,eu-west-1] [--explorer] [--cost-tags] [--csv untagged.csv]
import { writeFileSync } from "node:fs";
import {
  ResourceGroupsTaggingAPIClient,
  paginateGetResources,
} from "@aws-sdk/client-resource-groups-tagging-api";
import { ResourceExplorer2Client, paginateSearch } from "@aws-sdk/client-resource-explorer-2";
import { CostExplorerClient, ListCostAllocationTagsCommand } from "@aws-sdk/client-cost-explorer";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const required = (flag("--required") ?? "Owner,CostCenter").split(",").map((k) => k.trim()).filter(Boolean);
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim());
const csvPath = flag("--csv");
const useExplorer = args.includes("--explorer");
const checkCostTags = args.includes("--cost-tags");

interface Row { Region: string; Type: string; Resource: string; Missing: string; Source: string; Arn: string }

// arn:partition:service:region:account:resource -> "service:type" and a short id
function describeArn(arn: string): { type: string; id: string } {
  const parts = arn.split(":");
  const service = parts[2] ?? "?";
  const resource = parts.slice(5).join(":");
  if (service === "s3" && !resource.includes("/")) return { type: "s3:bucket", id: resource };
  const [kind, ...rest] = resource.split(/[/:]/);
  return { type: `${service}:${kind}`, id: rest.join("/") || kind || arn };
}

// Tags whose key starts with "aws:" are created by AWS and don't count toward your own tags.
function missingKeys(tags: { Key?: string; Value?: string }[]): string[] {
  const present = new Set(tags.filter((t) => t.Key && !t.Key.startsWith("aws:") && t.Value !== "").map((t) => t.Key));
  return required.filter((k) => !present.has(k));
}

async function taggingApiRows(region: string): Promise<Row[]> {
  const client = new ResourceGroupsTaggingAPIClient({ region });
  const rows: Row[] = [];
  for await (const page of paginateGetResources({ client }, { ResourcesPerPage: 100 })) {
    for (const r of page.ResourceTagMappingList ?? []) {
      const missing = missingKeys(r.Tags ?? []);
      if (!missing.length || !r.ResourceARN) continue;
      const { type, id } = describeArn(r.ResourceARN);
      rows.push({ Region: region, Type: type, Resource: id, Missing: missing.join(" "), Source: (r.Tags ?? []).length ? "tagging-api" : "tagging-api (tags removed)", Arn: r.ResourceARN });
    }
  }
  return rows;
}

// Needs a Resource Explorer index in the Region and a default view that includes tags.
async function explorerRows(region: string, seen: Set<string>): Promise<Row[]> {
  const client = new ResourceExplorer2Client({ region });
  const rows: Row[] = [];
  let total = 0;
  let complete = true;
  for await (const page of paginateSearch({ client }, { QueryString: `tag:none region:${region} resourcetype.supports:tags` })) {
    total = page.Count?.TotalResources ?? total;
    complete = page.Count?.Complete ?? complete;
    for (const r of page.Resources ?? []) {
      if (!r.Arn || seen.has(r.Arn)) continue;
      rows.push({ Region: region, Type: r.ResourceType ?? describeArn(r.Arn).type, Resource: describeArn(r.Arn).id, Missing: required.join(" "), Source: "resource-explorer (no user tags)", Arn: r.Arn });
    }
  }
  if (!complete || total > 1000) console.warn(`${region}: Resource Explorer returns at most 1,000 results; ${total} matched. Narrow the query by resourcetype:.`);
  return rows;
}

async function costTagStatus(): Promise<void> {
  const ce = new CostExplorerClient({ region: "us-east-1" });
  const res = await ce.send(new ListCostAllocationTagsCommand({ TagKeys: required, Type: "UserDefined" }));
  const status = new Map((res.CostAllocationTags ?? []).map((t) => [t.TagKey, t.Status]));
  for (const key of required) {
    console.log(`cost allocation tag ${key}: ${status.get(key) ?? "not found (no resource carries it yet, or not visible to this account)"}`);
  }
}

function toCsv(rows: Row[]): string {
  const cell = (v: string) => `"${v.replace(/"/g, '""')}"`;
  const header = ["Region", "Type", "Resource", "Missing", "Source"];
  return [header.join(","), ...rows.map((r) => [r.Region, r.Type, r.Resource, r.Missing, r.Source].map(cell).join(","))].join("\n") + "\n";
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of regions) {
    const found = await taggingApiRows(region);
    rows.push(...found);
    if (useExplorer) {
      const seen = new Set(found.map((r) => r.Arn));
      rows.push(...(await explorerRows(region, seen)));
    }
  }

  console.table(rows.slice(0, 50).map(({ Arn, ...shown }) => shown));
  const byType = new Map<string, number>();
  for (const r of rows) byType.set(r.Type, (byType.get(r.Type) ?? 0) + 1);
  console.log(`${rows.length} resources missing at least one of: ${required.join(", ")} (${regions.join(", ")})`);
  console.log("By type:", Object.fromEntries([...byType].sort((a, b) => b[1] - a[1])));

  if (checkCostTags) await costTagStatus();
  if (csvPath) {
    writeFileSync(csvPath, toCsv(rows));
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
  console.log("Report only: no tags were added or changed.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-resource-groups-tagging-api @aws-sdk/client-resource-explorer-2 @aws-sdk/client-cost-explorer
npm install --save-dev tsx typescript

# Tagged resources missing Owner or CostCenter in two Regions
AWS_PROFILE=readonly npx tsx find-untagged-aws-resources.ts --required Owner,CostCenter --regions us-east-1,eu-west-1

# Add never-tagged resources, check cost allocation status, and export a CSV
AWS_PROFILE=readonly npx tsx find-untagged-aws-resources.ts --required Owner,CostCenter \
  --regions us-east-1,eu-west-1 --explorer --cost-tags --csv untagged.csv

Sample output

Output

┌─────────┬─────────────┬───────────────────┬──────────────────────────┬────────────────────┬────────────────────────────────────┐
│ (index) │ Region      │ Type              │ Resource                 │ Missing            │ Source                             │
├─────────┼─────────────┼───────────────────┼──────────────────────────┼────────────────────┼────────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'ec2:instance'    │ 'i-0a1b2c3d4e5f60718'    │ 'CostCenter'       │ 'tagging-api'                      │
│ 1       │ 'us-east-1' │ 's3:bucket'       │ 'reports-export-2025'    │ 'Owner CostCenter' │ 'tagging-api (tags removed)'       │
│ 2       │ 'us-east-1' │ 'rds:db'          │ 'orders-replica'         │ 'Owner'            │ 'tagging-api'                      │
│ 3       │ 'us-east-1' │ 'ec2:volume'      │ 'vol-0f9e8d7c6b5a43210'  │ 'Owner CostCenter' │ 'resource-explorer (no user tags)' │
│ 4       │ 'eu-west-1' │ 'lambda:function' │ 'thumbnailer'            │ 'CostCenter'       │ 'tagging-api'                      │
│ 5       │ 'eu-west-1' │ 'ec2:snapshot'    │ 'snap-0123456789abcdef0' │ 'Owner CostCenter' │ 'resource-explorer (no user tags)' │
└─────────┴─────────────┴───────────────────┴──────────────────────────┴────────────────────┴────────────────────────────────────┘
6 resources missing at least one of: Owner, CostCenter (us-east-1, eu-west-1)
By type: {
  'ec2:instance': 1,
  's3:bucket': 1,
  'rds:db': 1,
  'ec2:volume': 1,
  'lambda:function': 1,
  'ec2:snapshot': 1
}
cost allocation tag Owner: Active
cost allocation tag CostCenter: Inactive
Wrote 6 rows to untagged.csv
Report only: no tags were added or changed.

ARNs and IDs are illustrative. The tags removed row is a resource that once had tags and now has none, which usually means someone cleared them by hand. The Inactive line matters more than it looks: CostCenter exists on resources but isn’t activated for billing, so Cost Explorer can’t group spend by it yet.

How do untagged resources show up on your bill?

A tag only helps with cost once it’s an activated cost allocation tag. After you apply a new key, it can take up to 24 hours to appear on the Cost allocation tags page of the Billing console, and up to another 24 hours to activate once you turn it on. Activation works on the tag key, so every value under CostCenter becomes reportable together. Until then, the costs still appear in reports, just with no value for that key. Once a key is active, it can also scope a team budget, as in the script to create an AWS budget alert with AWS SDK v3.

The FinOps Foundation describes this as the FinOps allocation capability: apportioning costs to the people responsible for them through accounts, tags and metadata, and moving from fixing tags after deployment to enforcing them before resources are created. This script is the “after” half. Run it weekly, share the CSV, and watch the count fall. Tags also make the other cleanups easier: the example to find and tag unattached EBS volumes labels its findings so owners can confirm before anything is deleted. An Owner tag also tells you who to ask before a rightsizing change, such as when you find Lambda functions with too much memory.

Troubleshooting

  • IAM roles show up as never tagged. Resource Explorer doesn’t index tags on IAM resources such as roles and users, so treat IAM rows from --explorer as unconfirmed and check them with IAM’s own tag calls.
  • ValidationException or UnauthorizedException from Resource Explorer. The Region has no index, no default view, or a view that doesn’t include tags. Turn on Resource Explorer, or drop --explorer.
  • The 1,000-result warning. Search returns only the first 1,000 matches. Split the scan by adding resourcetype:ec2:instance or similar filters to the query string.
  • PaginationTokenExpiredException. A Tagging API pagination token is valid for up to 15 minutes. Very large Regions can hit this if you pause inside the loop; keep the loop tight.
  • ThrottledException. GetResources has a per-second rate limit. The SDK retries it; for many Regions, scan them one after another as the script does, not in parallel. The guide to configure retry and timeout settings in AWS SDK for JavaScript v3 shows how to raise the attempts.
  • The cost tag says “not found”. The key hasn’t been applied to any resource yet, or you’re not in the management account.

Ask ChatWithCloud instead

For a quick answer without the setup, ask ChatWithCloud “Which EC2 instances in eu-west-1 don’t have an Owner tag?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result, the same way it can list AWS resources with natural language. It works with one profile and one Region per session, and it runs generated code without a confirmation step, so connect ChatWithCloud to a read-only AWS profile for questions like this. For a multi-Region CSV that you rerun every week, the script is the better tool. When the untagged pile turns out to be expensive, ask AI why your AWS bill increased to see which service is driving it.

Frequently asked questions

How do I find all untagged resources in my AWS account?

Run GetResources in every Region and flag resources missing your required keys, then run a Resource Explorer search for tag:none for resources that were never tagged. Neither call covers every Region at once unless you use an aggregator index.

Does the Tag Editor show untagged resources?

The console’s Tag Editor lets you search resources by Region and type and see their tags, which works for spot checks. For a repeatable report across Regions, script the APIs.

Why don’t untagged costs show up by tag in Cost Explorer?

A tag key must be activated as a cost allocation tag first, and activation can take up to 24 hours after the key appears. Resources without the key are grouped under no tag value.

Can this script add the missing tags?

No, it’s report-only on purpose: the right Owner value is a human decision. Fix tags in the infrastructure as code that created the resource, or the next deploy reverts them.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud